feat: enforce default agents and auto-reassign on delete
This commit is contained in:
parent
b7fc4123a6
commit
3cdd880344
6 changed files with 229 additions and 59 deletions
33
src-tauri/migrations/004_default_agents.sql
Normal file
33
src-tauri/migrations/004_default_agents.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
ALTER TABLE agents ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_default_per_role
|
||||||
|
ON agents(role)
|
||||||
|
WHERE is_default = 1;
|
||||||
|
|
||||||
|
INSERT INTO agents (id, name, role, tool, custom_prompt, is_default, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
'default-analyst-agent',
|
||||||
|
'Default Analyst',
|
||||||
|
'analyst',
|
||||||
|
'codex',
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
datetime('now'),
|
||||||
|
datetime('now')
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM agents WHERE role = 'analyst' AND is_default = 1
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO agents (id, name, role, tool, custom_prompt, is_default, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
'default-developer-agent',
|
||||||
|
'Default Developer',
|
||||||
|
'developer',
|
||||||
|
'claude_code',
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
datetime('now'),
|
||||||
|
datetime('now')
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM agents WHERE role = 'developer' AND is_default = 1
|
||||||
|
);
|
||||||
|
|
@ -4,6 +4,7 @@ use std::path::Path;
|
||||||
const MIGRATION_001: &str = include_str!("../migrations/001_init.sql");
|
const MIGRATION_001: &str = include_str!("../migrations/001_init.sql");
|
||||||
const MIGRATION_002: &str = include_str!("../migrations/002_add_last_polled.sql");
|
const MIGRATION_002: &str = include_str!("../migrations/002_add_last_polled.sql");
|
||||||
const MIGRATION_003: &str = include_str!("../migrations/003_add_agents.sql");
|
const MIGRATION_003: &str = include_str!("../migrations/003_add_agents.sql");
|
||||||
|
const MIGRATION_004: &str = include_str!("../migrations/004_default_agents.sql");
|
||||||
|
|
||||||
pub fn init(db_path: &Path) -> Result<Connection> {
|
pub fn init(db_path: &Path) -> Result<Connection> {
|
||||||
let conn = Connection::open(db_path)?;
|
let conn = Connection::open(db_path)?;
|
||||||
|
|
@ -41,6 +42,10 @@ fn migrate(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(MIGRATION_003)?;
|
conn.execute_batch(MIGRATION_003)?;
|
||||||
conn.pragma_update(None, "user_version", 3)?;
|
conn.pragma_update(None, "user_version", 3)?;
|
||||||
}
|
}
|
||||||
|
if version < 4 {
|
||||||
|
conn.execute_batch(MIGRATION_004)?;
|
||||||
|
conn.pragma_update(None, "user_version", 4)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +97,29 @@ mod tests {
|
||||||
let version: i32 = conn
|
let version: i32 = conn
|
||||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(version, 3);
|
assert_eq!(version, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_agents_are_seeded() {
|
||||||
|
let conn = init_in_memory().expect("should initialize");
|
||||||
|
|
||||||
|
let analyst_defaults: i32 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM agents WHERE role = 'analyst' AND is_default = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let developer_defaults: i32 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM agents WHERE role = 'developer' AND is_default = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(analyst_defaults, 1);
|
||||||
|
assert_eq!(developer_defaults, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
use rusqlite::{params, Connection, Result};
|
use rusqlite::{params, Connection, OptionalExtension, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub const DEFAULT_ANALYST_AGENT_ID: &str = "default-analyst-agent";
|
||||||
|
pub const DEFAULT_DEVELOPER_AGENT_ID: &str = "default-developer-agent";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AgentRole {
|
pub enum AgentRole {
|
||||||
|
|
@ -70,6 +73,7 @@ pub struct Agent {
|
||||||
pub role: AgentRole,
|
pub role: AgentRole,
|
||||||
pub tool: AgentTool,
|
pub tool: AgentTool,
|
||||||
pub custom_prompt: String,
|
pub custom_prompt: String,
|
||||||
|
pub is_default: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
@ -77,6 +81,7 @@ pub struct Agent {
|
||||||
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Agent> {
|
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Agent> {
|
||||||
let role_raw: String = row.get(2)?;
|
let role_raw: String = row.get(2)?;
|
||||||
let tool_raw: String = row.get(3)?;
|
let tool_raw: String = row.get(3)?;
|
||||||
|
let is_default_int: i32 = row.get(5)?;
|
||||||
|
|
||||||
Ok(Agent {
|
Ok(Agent {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
|
|
@ -84,8 +89,9 @@ fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Agent> {
|
||||||
role: AgentRole::from_str(&role_raw)?,
|
role: AgentRole::from_str(&role_raw)?,
|
||||||
tool: AgentTool::from_str(&tool_raw)?,
|
tool: AgentTool::from_str(&tool_raw)?,
|
||||||
custom_prompt: row.get(4)?,
|
custom_prompt: row.get(4)?,
|
||||||
created_at: row.get(5)?,
|
is_default: is_default_int != 0,
|
||||||
updated_at: row.get(6)?,
|
created_at: row.get(6)?,
|
||||||
|
updated_at: row.get(7)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,7 +107,7 @@ impl Agent {
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO agents (id, name, role, tool, custom_prompt, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
"INSERT INTO agents (id, name, role, tool, custom_prompt, is_default, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7)",
|
||||||
params![id, name, role.as_str(), tool.as_str(), custom_prompt, now, now],
|
params![id, name, role.as_str(), tool.as_str(), custom_prompt, now, now],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|
@ -111,6 +117,7 @@ impl Agent {
|
||||||
role,
|
role,
|
||||||
tool,
|
tool,
|
||||||
custom_prompt: custom_prompt.to_string(),
|
custom_prompt: custom_prompt.to_string(),
|
||||||
|
is_default: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
|
|
@ -118,7 +125,9 @@ impl Agent {
|
||||||
|
|
||||||
pub fn list(conn: &Connection) -> Result<Vec<Agent>> {
|
pub fn list(conn: &Connection) -> Result<Vec<Agent>> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, name, role, tool, custom_prompt, created_at, updated_at FROM agents ORDER BY role ASC, created_at DESC",
|
"SELECT id, name, role, tool, custom_prompt, is_default, created_at, updated_at
|
||||||
|
FROM agents
|
||||||
|
ORDER BY role ASC, is_default DESC, created_at DESC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], from_row)?;
|
let rows = stmt.query_map([], from_row)?;
|
||||||
rows.collect()
|
rows.collect()
|
||||||
|
|
@ -126,12 +135,23 @@ impl Agent {
|
||||||
|
|
||||||
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Agent> {
|
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Agent> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, name, role, tool, custom_prompt, created_at, updated_at FROM agents WHERE id = ?1",
|
"SELECT id, name, role, tool, custom_prompt, is_default, created_at, updated_at FROM agents WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
from_row,
|
from_row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_default_by_role(conn: &Connection, role: AgentRole) -> Result<Agent> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT id, name, role, tool, custom_prompt, is_default, created_at, updated_at
|
||||||
|
FROM agents
|
||||||
|
WHERE role = ?1 AND is_default = 1
|
||||||
|
LIMIT 1",
|
||||||
|
params![role.as_str()],
|
||||||
|
from_row,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update(
|
pub fn update(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
id: &str,
|
id: &str,
|
||||||
|
|
@ -140,7 +160,23 @@ impl Agent {
|
||||||
tool: AgentTool,
|
tool: AgentTool,
|
||||||
custom_prompt: &str,
|
custom_prompt: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let existing = Self::get_by_id(conn, id)?;
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
if existing.is_default {
|
||||||
|
if existing.name != name || existing.role != role || existing.tool != tool {
|
||||||
|
return Err(rusqlite::Error::InvalidParameterName(
|
||||||
|
"Default agents cannot change name, role, or tool".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE agents SET custom_prompt = ?1, updated_at = ?2 WHERE id = ?3",
|
||||||
|
params![custom_prompt, now, id],
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let affected = conn.execute(
|
let affected = conn.execute(
|
||||||
"UPDATE agents SET name = ?1, role = ?2, tool = ?3, custom_prompt = ?4, updated_at = ?5 WHERE id = ?6",
|
"UPDATE agents SET name = ?1, role = ?2, tool = ?3, custom_prompt = ?4, updated_at = ?5 WHERE id = ?6",
|
||||||
params![name, role.as_str(), tool.as_str(), custom_prompt, now, id],
|
params![name, role.as_str(), tool.as_str(), custom_prompt, now, id],
|
||||||
|
|
@ -154,16 +190,50 @@ impl Agent {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
|
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
|
||||||
|
let agent = Self::get_by_id(conn, id)?;
|
||||||
|
|
||||||
|
if agent.is_default {
|
||||||
|
return Err(rusqlite::Error::InvalidParameterName(
|
||||||
|
"Default agents cannot be deleted".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_agent_id: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM agents WHERE role = ?1 AND is_default = 1 LIMIT 1",
|
||||||
|
params![agent.role.as_str()],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
rusqlite::Error::InvalidParameterName(format!(
|
||||||
|
"No default agent found for role '{}'",
|
||||||
|
agent.role.as_str()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match agent.role {
|
||||||
|
AgentRole::Analyst => {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE watched_trackers SET analyst_agent_id = ?1 WHERE analyst_agent_id = ?2",
|
||||||
|
params![default_agent_id, id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
AgentRole::Developer => {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE watched_trackers SET developer_agent_id = ?1 WHERE developer_agent_id = ?2",
|
||||||
|
params![default_agent_id, id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE watched_trackers
|
"UPDATE watched_trackers
|
||||||
SET analyst_agent_id = CASE WHEN analyst_agent_id = ?1 THEN NULL ELSE analyst_agent_id END,
|
SET status = CASE
|
||||||
developer_agent_id = CASE WHEN developer_agent_id = ?1 THEN NULL ELSE developer_agent_id END,
|
WHEN analyst_agent_id IS NULL OR developer_agent_id IS NULL THEN 'invalid'
|
||||||
status = CASE
|
ELSE 'valid'
|
||||||
WHEN analyst_agent_id = ?1 OR developer_agent_id = ?1 THEN 'invalid'
|
END",
|
||||||
ELSE status
|
[],
|
||||||
END
|
|
||||||
WHERE analyst_agent_id = ?1 OR developer_agent_id = ?1",
|
|
||||||
params![id],
|
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let affected = conn.execute("DELETE FROM agents WHERE id = ?1", params![id])?;
|
let affected = conn.execute("DELETE FROM agents WHERE id = ?1", params![id])?;
|
||||||
|
|
@ -185,6 +255,19 @@ mod tests {
|
||||||
db::init_in_memory().expect("db init should succeed")
|
db::init_in_memory().expect("db init should succeed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_defaults_exist() {
|
||||||
|
let conn = setup();
|
||||||
|
|
||||||
|
let analyst = Agent::get_default_by_role(&conn, AgentRole::Analyst).unwrap();
|
||||||
|
let developer = Agent::get_default_by_role(&conn, AgentRole::Developer).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(analyst.id, DEFAULT_ANALYST_AGENT_ID);
|
||||||
|
assert_eq!(developer.id, DEFAULT_DEVELOPER_AGENT_ID);
|
||||||
|
assert!(analyst.is_default);
|
||||||
|
assert!(developer.is_default);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_insert_and_get_agent() {
|
fn test_insert_and_get_agent() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
|
|
@ -204,59 +287,61 @@ mod tests {
|
||||||
assert_eq!(found.role, AgentRole::Analyst);
|
assert_eq!(found.role, AgentRole::Analyst);
|
||||||
assert_eq!(found.tool, AgentTool::Codex);
|
assert_eq!(found.tool, AgentTool::Codex);
|
||||||
assert_eq!(found.custom_prompt, "Focus on root cause.");
|
assert_eq!(found.custom_prompt, "Focus on root cause.");
|
||||||
|
assert!(!found.is_default);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_update_agent() {
|
fn test_update_default_agent_allows_prompt_only() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
|
let analyst = Agent::get_default_by_role(&conn, AgentRole::Analyst).unwrap();
|
||||||
|
|
||||||
let created = Agent::insert(
|
let err = Agent::update(
|
||||||
&conn,
|
&conn,
|
||||||
"Dev Claude",
|
&analyst.id,
|
||||||
|
"Renamed",
|
||||||
AgentRole::Developer,
|
AgentRole::Developer,
|
||||||
AgentTool::ClaudeCode,
|
AgentTool::ClaudeCode,
|
||||||
"",
|
"new script",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("Default agents cannot change name, role, or tool")
|
||||||
|
);
|
||||||
|
|
||||||
Agent::update(
|
Agent::update(
|
||||||
&conn,
|
&conn,
|
||||||
&created.id,
|
&analyst.id,
|
||||||
"Dev Codex",
|
&analyst.name,
|
||||||
AgentRole::Developer,
|
analyst.role.clone(),
|
||||||
AgentTool::Codex,
|
analyst.tool.clone(),
|
||||||
"Write tests.",
|
"Prompt override",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let updated = Agent::get_by_id(&conn, &created.id).unwrap();
|
let updated = Agent::get_by_id(&conn, &analyst.id).unwrap();
|
||||||
assert_eq!(updated.name, "Dev Codex");
|
assert_eq!(updated.custom_prompt, "Prompt override");
|
||||||
assert_eq!(updated.tool, AgentTool::Codex);
|
|
||||||
assert_eq!(updated.custom_prompt, "Write tests.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_delete_agent_invalidates_trackers() {
|
fn test_delete_default_agent_is_rejected() {
|
||||||
|
let conn = setup();
|
||||||
|
let analyst = Agent::get_default_by_role(&conn, AgentRole::Analyst).unwrap();
|
||||||
|
|
||||||
|
let err = Agent::delete(&conn, &analyst.id).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("Default agents cannot be deleted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_agent_reassigns_trackers_to_default() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
let project = Project::insert(&conn, "P", "/tmp/p", None, "main").unwrap();
|
let project = Project::insert(&conn, "P", "/tmp/p", None, "main").unwrap();
|
||||||
|
|
||||||
let analyst = Agent::insert(
|
let analyst_default = Agent::get_default_by_role(&conn, AgentRole::Analyst).unwrap();
|
||||||
&conn,
|
let developer_default = Agent::get_default_by_role(&conn, AgentRole::Developer).unwrap();
|
||||||
"Analyst",
|
|
||||||
AgentRole::Analyst,
|
|
||||||
AgentTool::Codex,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let developer = Agent::insert(
|
let analyst = Agent::insert(&conn, "Analyst", AgentRole::Analyst, AgentTool::Codex, "")
|
||||||
&conn,
|
.unwrap();
|
||||||
"Developer",
|
|
||||||
AgentRole::Developer,
|
|
||||||
AgentTool::ClaudeCode,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let tracker = WatchedTracker::insert(
|
let tracker = WatchedTracker::insert(
|
||||||
&conn,
|
&conn,
|
||||||
|
|
@ -265,7 +350,7 @@ mod tests {
|
||||||
"Bugs",
|
"Bugs",
|
||||||
10,
|
10,
|
||||||
&analyst.id,
|
&analyst.id,
|
||||||
&developer.id,
|
&developer_default.id,
|
||||||
vec![],
|
vec![],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -273,8 +358,14 @@ mod tests {
|
||||||
Agent::delete(&conn, &analyst.id).unwrap();
|
Agent::delete(&conn, &analyst.id).unwrap();
|
||||||
|
|
||||||
let reloaded = WatchedTracker::get_by_id(&conn, &tracker.id).unwrap();
|
let reloaded = WatchedTracker::get_by_id(&conn, &tracker.id).unwrap();
|
||||||
assert_eq!(reloaded.status, "invalid");
|
assert_eq!(reloaded.status, "valid");
|
||||||
assert!(reloaded.analyst_agent_id.is_none());
|
assert_eq!(
|
||||||
assert_eq!(reloaded.developer_agent_id.as_deref(), Some(developer.id.as_str()));
|
reloaded.analyst_agent_id.as_deref(),
|
||||||
|
Some(analyst_default.id.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.developer_agent_id.as_deref(),
|
||||||
|
Some(developer_default.id.as_str())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export default function AgentForm() {
|
||||||
const [role, setRole] = useState<AgentRole>("analyst");
|
const [role, setRole] = useState<AgentRole>("analyst");
|
||||||
const [tool, setTool] = useState<AgentTool>("codex");
|
const [tool, setTool] = useState<AgentTool>("codex");
|
||||||
const [customPrompt, setCustomPrompt] = useState("");
|
const [customPrompt, setCustomPrompt] = useState("");
|
||||||
|
const [isDefaultAgent, setIsDefaultAgent] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [initializing, setInitializing] = useState(false);
|
const [initializing, setInitializing] = useState(false);
|
||||||
|
|
@ -28,6 +29,7 @@ export default function AgentForm() {
|
||||||
setRole(agent.role);
|
setRole(agent.role);
|
||||||
setTool(agent.tool);
|
setTool(agent.tool);
|
||||||
setCustomPrompt(agent.custom_prompt);
|
setCustomPrompt(agent.custom_prompt);
|
||||||
|
setIsDefaultAgent(agent.is_default);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(getErrorMessage(err));
|
setError(getErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -63,6 +65,11 @@ export default function AgentForm() {
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{initializing && <div className="text-sm text-gray-500">Loading agent...</div>}
|
{initializing && <div className="text-sm text-gray-500">Loading agent...</div>}
|
||||||
|
{isEditing && isDefaultAgent && (
|
||||||
|
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700">
|
||||||
|
This is a default agent. Only its script/prompt can be modified.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">Name</label>
|
<label className="mb-1 block text-sm font-medium text-gray-700">Name</label>
|
||||||
|
|
@ -70,6 +77,7 @@ export default function AgentForm() {
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={isEditing && isDefaultAgent}
|
||||||
required
|
required
|
||||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
|
|
@ -80,6 +88,7 @@ export default function AgentForm() {
|
||||||
<select
|
<select
|
||||||
value={role}
|
value={role}
|
||||||
onChange={(e) => setRole(e.target.value as AgentRole)}
|
onChange={(e) => setRole(e.target.value as AgentRole)}
|
||||||
|
disabled={isEditing && isDefaultAgent}
|
||||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="analyst">Analyst</option>
|
<option value="analyst">Analyst</option>
|
||||||
|
|
@ -92,6 +101,7 @@ export default function AgentForm() {
|
||||||
<select
|
<select
|
||||||
value={tool}
|
value={tool}
|
||||||
onChange={(e) => setTool(e.target.value as AgentTool)}
|
onChange={(e) => setTool(e.target.value as AgentTool)}
|
||||||
|
disabled={isEditing && isDefaultAgent}
|
||||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="codex">Codex</option>
|
<option value="codex">Codex</option>
|
||||||
|
|
@ -101,7 +111,7 @@ export default function AgentForm() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
Custom prompt (appended to built-in prompt)
|
Script / custom prompt (appended to built-in prompt)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={12}
|
rows={12}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,11 @@ export default function AgentList() {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{agent.name}</span>
|
<span className="text-sm font-medium">{agent.name}</span>
|
||||||
|
{agent.is_default && (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||||
{roleLabel(agent.role)}
|
{roleLabel(agent.role)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -107,13 +112,15 @@ export default function AgentList() {
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
{!agent.is_default && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setAgentToDelete(agent)}
|
type="button"
|
||||||
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
onClick={() => setAgentToDelete(agent)}
|
||||||
>
|
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||||
Delete
|
>
|
||||||
</button>
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -122,7 +129,7 @@ export default function AgentList() {
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={agentToDelete !== null}
|
isOpen={agentToDelete !== null}
|
||||||
title="Delete agent"
|
title="Delete agent"
|
||||||
message="Delete this agent? Trackers using it will become invalid until reconfigured."
|
message="Delete this agent? Trackers using it will be reassigned to the default agent for the same role."
|
||||||
confirmLabel={deleting ? "Deleting..." : "Delete"}
|
confirmLabel={deleting ? "Deleting..." : "Delete"}
|
||||||
confirmDisabled={deleting}
|
confirmDisabled={deleting}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export interface Agent {
|
||||||
role: AgentRole;
|
role: AgentRole;
|
||||||
tool: AgentTool;
|
tool: AgentTool;
|
||||||
custom_prompt: string;
|
custom_prompt: string;
|
||||||
|
is_default: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue