mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Refresh built-in personas: Solo, Kit, Scout + Kit & Scout team (#499)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,8 @@ const rules = [
|
||||
|
||||
// Exceptions should stay rare and temporary. Prefer splitting files instead.
|
||||
const overrides = new Map([
|
||||
["src-tauri/src/managed_agents/personas.rs", 830], // built-in persona system prompts + persona pack import/uninstall/list + uninstall safety check
|
||||
["src-tauri/src/managed_agents/personas.rs", 900], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check
|
||||
["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests
|
||||
["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests
|
||||
["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards
|
||||
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
|
||||
|
||||
@@ -6,8 +6,8 @@ use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
encode_team_json, ensure_persona_ids_are_active, load_personas, load_teams,
|
||||
parse_team_json, save_teams, CreateTeamRequest, ParsedTeamPreview, TeamRecord,
|
||||
UpdateTeamRequest,
|
||||
parse_team_json, save_teams, validate_team_deletion, CreateTeamRequest, ParsedTeamPreview,
|
||||
TeamRecord, UpdateTeamRequest,
|
||||
},
|
||||
util::now_iso,
|
||||
};
|
||||
@@ -58,6 +58,7 @@ pub fn create_team(
|
||||
name,
|
||||
description,
|
||||
persona_ids: input.persona_ids,
|
||||
is_builtin: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -104,11 +105,12 @@ pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Re
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut teams = load_teams(&app)?;
|
||||
let original_len = teams.len();
|
||||
let team = teams
|
||||
.iter()
|
||||
.find(|record| record.id == id)
|
||||
.ok_or_else(|| format!("team {id} not found"))?;
|
||||
validate_team_deletion(team)?;
|
||||
teams.retain(|record| record.id != id);
|
||||
if teams.len() == original_len {
|
||||
return Err(format!("team {id} not found"));
|
||||
}
|
||||
save_teams(&app, &teams)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -29,12 +29,12 @@ fn merge_personas_adds_missing_built_ins() {
|
||||
assert!(changed);
|
||||
assert_eq!(records.len(), BUILT_IN_PERSONAS.len());
|
||||
assert!(records.iter().all(|record| record.is_builtin));
|
||||
assert!(records.iter().all(|record| !record.is_active));
|
||||
assert!(records.iter().all(|record| record.is_active));
|
||||
let display_names: Vec<&str> = records
|
||||
.iter()
|
||||
.map(|record| record.display_name.as_str())
|
||||
.collect();
|
||||
assert_eq!(display_names, vec!["Solo", "Ralph", "Scout", "Reviewer"]);
|
||||
assert_eq!(display_names, vec!["Solo", "Kit", "Scout"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -48,7 +48,7 @@ fn merge_personas_preserves_custom_records() {
|
||||
|
||||
#[test]
|
||||
fn merge_personas_restores_builtin_defaults() {
|
||||
let mut edited_builtin = custom_persona("builtin:reviewer", "My Reviewer");
|
||||
let mut edited_builtin = custom_persona("builtin:solo", "My Solo");
|
||||
edited_builtin.is_builtin = true;
|
||||
edited_builtin.is_active = true;
|
||||
let original_created_at = edited_builtin.created_at.clone();
|
||||
@@ -57,15 +57,19 @@ fn merge_personas_restores_builtin_defaults() {
|
||||
let (records, changed) = merge_personas(vec![edited_builtin], "2026-03-19T00:00:00Z");
|
||||
|
||||
assert!(changed);
|
||||
let reviewer = records
|
||||
let solo = records
|
||||
.iter()
|
||||
.find(|record| record.id == "builtin:reviewer")
|
||||
.expect("reviewer built-in should exist");
|
||||
assert_eq!(reviewer.display_name, "Reviewer");
|
||||
assert_eq!(reviewer.avatar_url, None);
|
||||
assert_eq!(reviewer.created_at, original_created_at);
|
||||
assert_eq!(reviewer.updated_at, original_updated_at);
|
||||
assert!(reviewer.is_active);
|
||||
.find(|record| record.id == "builtin:solo")
|
||||
.expect("solo built-in should exist");
|
||||
let canonical = BUILT_IN_PERSONAS
|
||||
.iter()
|
||||
.find(|persona| persona.id == "builtin:solo")
|
||||
.expect("solo built-in definition should exist");
|
||||
assert_eq!(solo.display_name, canonical.display_name);
|
||||
assert_eq!(solo.avatar_url.as_deref(), canonical.avatar_url,);
|
||||
assert_eq!(solo.created_at, original_created_at);
|
||||
assert_eq!(solo.updated_at, original_updated_at);
|
||||
assert!(solo.is_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -97,7 +101,7 @@ fn merge_personas_restores_builtin_name_pool_and_preserves_is_active() {
|
||||
|
||||
#[test]
|
||||
fn merge_personas_backfills_new_builtins_for_existing_store() {
|
||||
let mut legacy_builtins = vec![custom_persona("builtin:reviewer", "Reviewer")];
|
||||
let mut legacy_builtins = vec![custom_persona("builtin:solo", "Solo")];
|
||||
for persona in &mut legacy_builtins {
|
||||
persona.is_builtin = true;
|
||||
persona.avatar_url = None;
|
||||
@@ -106,24 +110,43 @@ fn merge_personas_backfills_new_builtins_for_existing_store() {
|
||||
let (records, changed) = merge_personas(legacy_builtins, "2026-03-19T00:00:00Z");
|
||||
|
||||
assert!(changed);
|
||||
assert!(records.iter().any(|record| record.id == "builtin:reviewer"));
|
||||
assert!(records.iter().any(|record| record.id == "builtin:ralph"));
|
||||
assert!(records.iter().any(|record| record.id == "builtin:kit"));
|
||||
assert!(records.iter().any(|record| record.id == "builtin:scout"));
|
||||
assert!(records.iter().any(|record| record.id == "builtin:solo"));
|
||||
assert!(
|
||||
records
|
||||
.iter()
|
||||
.find(|record| record.id == "builtin:reviewer")
|
||||
.expect("reviewer built-in should exist")
|
||||
.is_active
|
||||
);
|
||||
assert!(
|
||||
!records
|
||||
.iter()
|
||||
.find(|record| record.id == "builtin:solo")
|
||||
.expect("solo built-in should exist")
|
||||
.is_active
|
||||
);
|
||||
assert!(
|
||||
records
|
||||
.iter()
|
||||
.find(|record| record.id == "builtin:kit")
|
||||
.expect("kit built-in should exist")
|
||||
.is_active
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_personas_demotes_retired_builtins() {
|
||||
let mut retired = custom_persona("builtin:reviewer", "Reviewer");
|
||||
retired.is_builtin = true;
|
||||
retired.is_active = true;
|
||||
let original_created_at = retired.created_at.clone();
|
||||
|
||||
let (records, changed) = merge_personas(vec![retired], "2026-04-01T00:00:00Z");
|
||||
|
||||
assert!(changed);
|
||||
let demoted = records
|
||||
.iter()
|
||||
.find(|record| record.id == "builtin:reviewer")
|
||||
.expect("retired built-in should be retained as a custom persona");
|
||||
assert!(!demoted.is_builtin);
|
||||
assert!(demoted.is_active);
|
||||
assert_eq!(demoted.created_at, original_created_at);
|
||||
assert_eq!(demoted.updated_at, "2026-04-01T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,10 @@ use std::{fs, path::PathBuf};
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::managed_agents::{managed_agents_base_dir, PersonaRecord, TeamRecord};
|
||||
use crate::{
|
||||
managed_agents::{managed_agents_base_dir, PersonaRecord, TeamRecord},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -28,24 +31,118 @@ fn teams_store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
|
||||
fn sort_teams(records: &mut [TeamRecord]) {
|
||||
records.sort_by(|left, right| {
|
||||
left.name
|
||||
.to_lowercase()
|
||||
.cmp(&right.name.to_lowercase())
|
||||
let left_builtin = if left.is_builtin { 0 } else { 1 };
|
||||
let right_builtin = if right.is_builtin { 0 } else { 1 };
|
||||
left_builtin
|
||||
.cmp(&right_builtin)
|
||||
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
}
|
||||
|
||||
pub fn load_teams(app: &AppHandle) -> Result<Vec<TeamRecord>, String> {
|
||||
let path = teams_store_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in teams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct BuiltInTeam {
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
description: Option<&'static str>,
|
||||
persona_ids: &'static [&'static str],
|
||||
}
|
||||
|
||||
const BUILT_IN_TEAMS: &[BuiltInTeam] = &[BuiltInTeam {
|
||||
id: "builtin-team:kit-scout",
|
||||
name: "Kit & Scout",
|
||||
description: Some("Kit orchestrates and builds; Scout researches, plans, and reviews."),
|
||||
persona_ids: &["builtin:kit", "builtin:scout"],
|
||||
}];
|
||||
|
||||
fn built_in_team_records(now: &str) -> Vec<TeamRecord> {
|
||||
BUILT_IN_TEAMS
|
||||
.iter()
|
||||
.map(|team| TeamRecord {
|
||||
id: team.id.to_string(),
|
||||
name: team.name.to_string(),
|
||||
description: team.description.map(|s| s.to_string()),
|
||||
persona_ids: team.persona_ids.iter().map(|s| s.to_string()).collect(),
|
||||
is_builtin: true,
|
||||
created_at: now.to_string(),
|
||||
updated_at: now.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn built_in_team_order(id: &str) -> Option<usize> {
|
||||
BUILT_IN_TEAMS.iter().position(|team| team.id == id)
|
||||
}
|
||||
|
||||
/// Add missing built-in teams, demote stale built-ins, and preserve any
|
||||
/// user customizations to existing built-in teams (name, description,
|
||||
/// persona membership). Returns the merged list and whether the store
|
||||
/// changed.
|
||||
fn merge_teams(mut stored: Vec<TeamRecord>, now: &str) -> (Vec<TeamRecord>, bool) {
|
||||
let mut changed = false;
|
||||
|
||||
for built_in in built_in_team_records(now) {
|
||||
if let Some(existing) = stored.iter_mut().find(|record| record.id == built_in.id) {
|
||||
if !existing.is_builtin {
|
||||
existing.is_builtin = true;
|
||||
existing.updated_at = now.to_string();
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
stored.push(built_in);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read teams store: {error}"))?;
|
||||
let mut records: Vec<TeamRecord> = serde_json::from_str(&content)
|
||||
.map_err(|error| format!("failed to parse teams store: {error}"))?;
|
||||
// Demote any stored team flagged as built-in whose id is no longer in
|
||||
// BUILT_IN_TEAMS (e.g. a built-in that has been retired). The record
|
||||
// stays so existing references keep working; it becomes a user-owned
|
||||
// custom team they can edit or delete.
|
||||
for record in stored.iter_mut() {
|
||||
if record.is_builtin && built_in_team_order(&record.id).is_none() {
|
||||
record.is_builtin = false;
|
||||
record.updated_at = now.to_string();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
(stored, changed)
|
||||
}
|
||||
|
||||
/// Reject deletion of built-in teams. Mirrors `validate_persona_deletion`
|
||||
/// for personas — built-ins always come back via `merge_teams` on the
|
||||
/// next load, so blocking the delete avoids a confusing "keeps coming
|
||||
/// back" UX.
|
||||
pub fn validate_team_deletion(team: &TeamRecord) -> Result<(), String> {
|
||||
if team.is_builtin {
|
||||
return Err("Built-in teams cannot be deleted.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_teams(app: &AppHandle) -> Result<Vec<TeamRecord>, String> {
|
||||
let path = teams_store_path(app)?;
|
||||
let now = now_iso();
|
||||
|
||||
let records = if path.exists() {
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read teams store: {error}"))?;
|
||||
serde_json::from_str::<Vec<TeamRecord>>(&content)
|
||||
.map_err(|error| format!("failed to parse teams store: {error}"))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let (mut records, changed) = merge_teams(records, &now);
|
||||
sort_teams(&mut records);
|
||||
|
||||
if changed || !path.exists() {
|
||||
save_teams(app, &records)?;
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
@@ -179,7 +276,10 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result<ParsedTeamPreview, String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{encode_team_json, parse_team_json, sort_teams};
|
||||
use super::{
|
||||
encode_team_json, merge_teams, parse_team_json, sort_teams, validate_team_deletion,
|
||||
BUILT_IN_TEAMS,
|
||||
};
|
||||
use crate::managed_agents::{PersonaRecord, TeamRecord};
|
||||
|
||||
fn team(id: &str, name: &str) -> TeamRecord {
|
||||
@@ -188,6 +288,7 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
persona_ids: Vec::new(),
|
||||
is_builtin: false,
|
||||
created_at: "2026-03-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-03-20T00:00:00Z".to_string(),
|
||||
}
|
||||
@@ -347,4 +448,93 @@ mod tests {
|
||||
assert!(parsed.personas.is_empty());
|
||||
assert_eq!(parsed.name, "Solo");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// merge_teams + validate_team_deletion tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn merge_teams_adds_missing_built_ins() {
|
||||
let (records, changed) = merge_teams(Vec::new(), "2026-05-07T00:00:00Z");
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(records.len(), BUILT_IN_TEAMS.len());
|
||||
assert!(records.iter().all(|record| record.is_builtin));
|
||||
let names: Vec<&str> = records.iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Kit & Scout"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_teams_preserves_user_customizations_to_builtin() {
|
||||
let mut customized = team("builtin-team:kit-scout", "Kit & Scout (mine)");
|
||||
customized.is_builtin = true;
|
||||
customized.persona_ids = vec!["builtin:kit".to_string()];
|
||||
|
||||
let (records, _changed) = merge_teams(vec![customized], "2026-05-07T00:00:00Z");
|
||||
|
||||
let kit_scout = records
|
||||
.iter()
|
||||
.find(|t| t.id == "builtin-team:kit-scout")
|
||||
.expect("Kit & Scout built-in should exist");
|
||||
assert_eq!(kit_scout.name, "Kit & Scout (mine)");
|
||||
assert_eq!(kit_scout.persona_ids, vec!["builtin:kit".to_string()]);
|
||||
assert!(kit_scout.is_builtin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_teams_preserves_unrelated_user_teams() {
|
||||
let user_team = team("user-uuid", "My Team");
|
||||
let (records, _changed) = merge_teams(vec![user_team], "2026-05-07T00:00:00Z");
|
||||
|
||||
assert!(records.iter().any(|t| t.id == "user-uuid"));
|
||||
assert!(records.iter().any(|t| t.id == "builtin-team:kit-scout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_teams_demotes_retired_built_ins() {
|
||||
let mut retired = team("builtin-team:legacy", "Legacy");
|
||||
retired.is_builtin = true;
|
||||
|
||||
let (records, changed) = merge_teams(vec![retired], "2026-05-07T00:00:00Z");
|
||||
|
||||
assert!(changed);
|
||||
let demoted = records
|
||||
.iter()
|
||||
.find(|t| t.id == "builtin-team:legacy")
|
||||
.expect("retired built-in should be retained as a custom team");
|
||||
assert!(!demoted.is_builtin);
|
||||
assert_eq!(demoted.updated_at, "2026-05-07T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_teams_repromotes_existing_builtin_marked_as_custom() {
|
||||
// If someone hand-edits the store and flips is_builtin to false on a
|
||||
// canonical built-in id, merge_teams should restore the flag.
|
||||
let mut downgraded = team("builtin-team:kit-scout", "Kit & Scout");
|
||||
downgraded.is_builtin = false;
|
||||
|
||||
let (records, changed) = merge_teams(vec![downgraded], "2026-05-07T00:00:00Z");
|
||||
|
||||
assert!(changed);
|
||||
let kit_scout = records
|
||||
.iter()
|
||||
.find(|t| t.id == "builtin-team:kit-scout")
|
||||
.expect("Kit & Scout should exist");
|
||||
assert!(kit_scout.is_builtin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_team_deletion_rejects_built_ins() {
|
||||
let mut built_in = team("builtin-team:kit-scout", "Kit & Scout");
|
||||
built_in.is_builtin = true;
|
||||
|
||||
let err = validate_team_deletion(&built_in).unwrap_err();
|
||||
assert_eq!(err, "Built-in teams cannot be deleted.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_team_deletion_allows_custom_teams() {
|
||||
let custom = team("user-uuid", "My Team");
|
||||
assert!(validate_team_deletion(&custom).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,8 @@ pub struct TeamRecord {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub persona_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_builtin: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function createPersona(id, displayName, overrides = {}) {
|
||||
test("getCatalogPersonas keeps built-ins visible whether selected or not", () => {
|
||||
const personas = [
|
||||
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }),
|
||||
createPersona("builtin:ralph", "Ralph", {
|
||||
createPersona("builtin:kit", "Kit", {
|
||||
isBuiltIn: true,
|
||||
isActive: true,
|
||||
}),
|
||||
@@ -36,14 +36,14 @@ test("getCatalogPersonas keeps built-ins visible whether selected or not", () =>
|
||||
|
||||
assert.deepEqual(
|
||||
getCatalogPersonas(personas).map((persona) => persona.id),
|
||||
["builtin:ralph", "builtin:solo"],
|
||||
["builtin:kit", "builtin:solo"],
|
||||
);
|
||||
});
|
||||
|
||||
test("getCatalogSelectionState keeps built-in selection rules in one place", () => {
|
||||
const personas = [
|
||||
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }),
|
||||
createPersona("builtin:ralph", "Ralph", {
|
||||
createPersona("builtin:kit", "Kit", {
|
||||
isBuiltIn: true,
|
||||
isActive: true,
|
||||
}),
|
||||
@@ -54,11 +54,11 @@ test("getCatalogSelectionState keeps built-in selection rules in one place", ()
|
||||
|
||||
assert.deepEqual(
|
||||
state.catalogPersonas.map((persona) => persona.id),
|
||||
["builtin:ralph", "builtin:solo"],
|
||||
["builtin:kit", "builtin:solo"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.selectedCatalogPersonas.map((persona) => persona.id),
|
||||
["builtin:ralph"],
|
||||
["builtin:kit"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.unselectedCatalogPersonas.map((persona) => persona.id),
|
||||
@@ -69,7 +69,7 @@ test("getCatalogSelectionState keeps built-in selection rules in one place", ()
|
||||
test("getCatalogPersonas keeps chooser order stable when selection changes", () => {
|
||||
const inactiveFirst = [
|
||||
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }),
|
||||
createPersona("builtin:ralph", "Ralph", {
|
||||
createPersona("builtin:kit", "Kit", {
|
||||
isBuiltIn: true,
|
||||
isActive: true,
|
||||
}),
|
||||
@@ -80,7 +80,7 @@ test("getCatalogPersonas keeps chooser order stable when selection changes", ()
|
||||
];
|
||||
const activeFirst = [
|
||||
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: true }),
|
||||
createPersona("builtin:ralph", "Ralph", {
|
||||
createPersona("builtin:kit", "Kit", {
|
||||
isBuiltIn: true,
|
||||
isActive: false,
|
||||
}),
|
||||
@@ -136,7 +136,7 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => {
|
||||
test("getPersonaLibraryState keeps the working library and full catalog in one place", () => {
|
||||
const personas = [
|
||||
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }),
|
||||
createPersona("builtin:ralph", "Ralph", {
|
||||
createPersona("builtin:kit", "Kit", {
|
||||
isBuiltIn: true,
|
||||
isActive: true,
|
||||
}),
|
||||
@@ -147,11 +147,11 @@ test("getPersonaLibraryState keeps the working library and full catalog in one p
|
||||
|
||||
assert.deepEqual(
|
||||
state.libraryPersonas.map((persona) => persona.id),
|
||||
["builtin:ralph", "custom:builder"],
|
||||
["builtin:kit", "custom:builder"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.catalogPersonas.map((persona) => persona.id),
|
||||
["builtin:ralph", "builtin:solo"],
|
||||
["builtin:kit", "builtin:solo"],
|
||||
);
|
||||
assert.equal(state.personaLabelsById["builtin:solo"], "Solo");
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ test("emptyResolvedTeamPersonas starts empty but complete", () => {
|
||||
test("resolveTeamPersonas preserves team order and marks complete teams usable", () => {
|
||||
const personas = [
|
||||
createPersona("persona-1", "Solo"),
|
||||
createPersona("persona-2", "Ralph"),
|
||||
createPersona("persona-2", "Kit"),
|
||||
];
|
||||
const resolution = resolveTeamPersonas(
|
||||
createTeam("team-1", ["persona-2", "persona-1"]),
|
||||
@@ -60,7 +60,7 @@ test("resolveTeamPersonas preserves team order and marks complete teams usable",
|
||||
assert.deepEqual(resolution.resolvedPersonaIds, ["persona-2", "persona-1"]);
|
||||
assert.deepEqual(
|
||||
resolution.resolvedPersonas.map((persona) => persona.displayName),
|
||||
["Ralph", "Solo"],
|
||||
["Kit", "Solo"],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ function createPersona(id, displayName) {
|
||||
test("pickQuickBotPersonas prefers recents before defaults", () => {
|
||||
const personas = [
|
||||
createPersona("builtin:solo", "Solo"),
|
||||
createPersona("builtin:ralph", "Ralph"),
|
||||
createPersona("builtin:kit", "Kit"),
|
||||
createPersona("builtin:reviewer", "Reviewer"),
|
||||
];
|
||||
|
||||
@@ -29,7 +29,7 @@ test("pickQuickBotPersonas prefers recents before defaults", () => {
|
||||
pickQuickBotPersonas(personas, ["builtin:reviewer"]).map(
|
||||
(persona) => persona.id,
|
||||
),
|
||||
["builtin:reviewer", "builtin:solo", "builtin:ralph"],
|
||||
["builtin:reviewer", "builtin:solo", "builtin:kit"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ test("pickQuickBotPersonas falls back to any active personas when defaults are m
|
||||
test("pickQuickBotPersonas skips duplicate and missing recents", () => {
|
||||
const personas = [
|
||||
createPersona("builtin:solo", "Solo"),
|
||||
createPersona("builtin:ralph", "Ralph"),
|
||||
createPersona("builtin:kit", "Kit"),
|
||||
createPersona("builtin:scout", "Scout"),
|
||||
];
|
||||
|
||||
@@ -58,8 +58,8 @@ test("pickQuickBotPersonas skips duplicate and missing recents", () => {
|
||||
"builtin:solo",
|
||||
"missing",
|
||||
"builtin:solo",
|
||||
"builtin:ralph",
|
||||
"builtin:kit",
|
||||
]).map((persona) => persona.id),
|
||||
["builtin:solo", "builtin:ralph", "builtin:scout"],
|
||||
["builtin:solo", "builtin:kit", "builtin:scout"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ const MAX_RECENTS = 8;
|
||||
|
||||
// Default persona display names to seed the list when empty.
|
||||
// These are resolved to IDs by the consumer.
|
||||
export const DEFAULT_PERSONA_NAMES = ["Solo", "Ralph", "Scout"] as const;
|
||||
export const DEFAULT_PERSONA_NAMES = ["Solo", "Kit", "Scout"] as const;
|
||||
|
||||
export function pickQuickBotPersonas(
|
||||
personas: readonly AgentPersona[],
|
||||
|
||||
@@ -49,7 +49,7 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () =>
|
||||
test("editPersonaDialogState preserves the persona id for updates", () => {
|
||||
const state = editPersonaDialogState({
|
||||
id: "persona-2",
|
||||
displayName: "Ralph",
|
||||
displayName: "Kit",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "Keep it weird.",
|
||||
provider: null,
|
||||
@@ -65,7 +65,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => {
|
||||
assert.equal(state.submitLabel, "Save changes");
|
||||
assert.deepEqual(state.initialValues, {
|
||||
id: "persona-2",
|
||||
displayName: "Ralph",
|
||||
displayName: "Kit",
|
||||
avatarUrl: "",
|
||||
systemPrompt: "Keep it weird.",
|
||||
provider: undefined,
|
||||
|
||||
@@ -10,6 +10,7 @@ type RawTeam = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
persona_ids: string[];
|
||||
is_builtin?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -20,6 +21,7 @@ function fromRawTeam(team: RawTeam): AgentTeam {
|
||||
name: team.name,
|
||||
description: team.description,
|
||||
personaIds: team.persona_ids,
|
||||
isBuiltin: team.is_builtin ?? false,
|
||||
createdAt: team.created_at,
|
||||
updatedAt: team.updated_at,
|
||||
};
|
||||
|
||||
@@ -485,6 +485,7 @@ export type AgentTeam = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
personaIds: string[];
|
||||
isBuiltin: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -392,6 +392,7 @@ type RawTeam = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
persona_ids: string[];
|
||||
is_builtin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -794,10 +795,10 @@ function resetMockPersonas() {
|
||||
updated_at: now,
|
||||
},
|
||||
{
|
||||
id: "builtin:ralph",
|
||||
display_name: "Ralph",
|
||||
id: "builtin:kit",
|
||||
display_name: "Kit",
|
||||
avatar_url: null,
|
||||
system_prompt: "You are Ralph.",
|
||||
system_prompt: "You are Kit.",
|
||||
is_builtin: true,
|
||||
is_active: false,
|
||||
created_at: now,
|
||||
@@ -813,16 +814,6 @@ function resetMockPersonas() {
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
{
|
||||
id: "builtin:reviewer",
|
||||
display_name: "Reviewer",
|
||||
avatar_url: null,
|
||||
system_prompt: "You are Reviewer.",
|
||||
is_builtin: true,
|
||||
is_active: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3846,6 +3837,7 @@ async function handleCreateTeam(args: {
|
||||
name: args.input.name.trim(),
|
||||
description: args.input.description?.trim() || null,
|
||||
persona_ids: [...args.input.personaIds],
|
||||
is_builtin: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -3876,6 +3868,10 @@ async function handleUpdateTeam(args: {
|
||||
}
|
||||
|
||||
async function handleDeleteTeam(args: { id: string }): Promise<void> {
|
||||
const team = mockTeams.find((candidate) => candidate.id === args.id);
|
||||
if (team?.is_builtin) {
|
||||
throw new Error("Built-in teams cannot be deleted.");
|
||||
}
|
||||
mockTeams = mockTeams.filter((candidate) => candidate.id !== args.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ test("built-in personas are chosen from the dialog and can be selected", async (
|
||||
await expect(page.getByTestId("agents-persona-catalog")).toHaveCount(0);
|
||||
await page.getByTestId("open-persona-catalog").click();
|
||||
await expect(page.getByTestId("persona-catalog-dialog")).toContainText(
|
||||
"Reviewer",
|
||||
"Scout",
|
||||
);
|
||||
await expect(page.getByTestId("persona-catalog-dialog-header")).toBeVisible();
|
||||
await expect(
|
||||
@@ -167,33 +167,29 @@ test("built-in personas are chosen from the dialog and can be selected", async (
|
||||
await expect(page.getByRole("tooltip")).toHaveCount(0);
|
||||
const initialCatalogOrder = await getCatalogOrder(page);
|
||||
|
||||
await page
|
||||
.getByTestId("persona-catalog-card-target-builtin:reviewer")
|
||||
.click();
|
||||
await page.getByTestId("persona-catalog-card-target-builtin:scout").click();
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast]")
|
||||
.filter({ hasText: "Selected Reviewer for My Agents." }),
|
||||
.filter({ hasText: "Selected Scout for My Agents." }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agents-library-personas")).toContainText(
|
||||
"Reviewer",
|
||||
"Scout",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("persona-catalog-card-target-builtin:reviewer"),
|
||||
page.getByTestId("persona-catalog-card-target-builtin:scout"),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder);
|
||||
|
||||
await page
|
||||
.getByTestId("persona-catalog-card-target-builtin:reviewer")
|
||||
.click();
|
||||
await page.getByTestId("persona-catalog-card-target-builtin:scout").click();
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast]")
|
||||
.filter({ hasText: "Deselected Reviewer from My Agents." }),
|
||||
.filter({ hasText: "Deselected Scout from My Agents." }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("agents-library-personas")).not.toContainText(
|
||||
"Reviewer",
|
||||
"Scout",
|
||||
);
|
||||
await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder);
|
||||
});
|
||||
@@ -205,11 +201,9 @@ test("persona catalog can reopen from the populated library header", async ({
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByTestId("open-persona-catalog").click();
|
||||
|
||||
await page
|
||||
.getByTestId("persona-catalog-card-target-builtin:reviewer")
|
||||
.click();
|
||||
await page.getByTestId("persona-catalog-card-target-builtin:scout").click();
|
||||
await expect(page.getByTestId("agents-library-personas")).toContainText(
|
||||
"Reviewer",
|
||||
"Scout",
|
||||
);
|
||||
|
||||
await page.getByTestId("persona-catalog-dialog-done").click();
|
||||
@@ -217,7 +211,7 @@ test("persona catalog can reopen from the populated library header", async ({
|
||||
|
||||
await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("persona-catalog-card-target-builtin:reviewer"),
|
||||
page.getByTestId("persona-catalog-card-target-builtin:scout"),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
@@ -247,23 +241,23 @@ test("catalog details sheet shows the full persona details", async ({
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByTestId("open-persona-catalog").click();
|
||||
|
||||
await page.getByTestId("persona-catalog-details-builtin:reviewer").click();
|
||||
await page.getByTestId("persona-catalog-details-builtin:scout").click();
|
||||
const detailSelectionTarget = page.getByTestId(
|
||||
"persona-catalog-detail-selection-target-builtin:reviewer",
|
||||
"persona-catalog-detail-selection-target-builtin:scout",
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("persona-catalog-details-sheet")).toContainText(
|
||||
"Reviewer",
|
||||
"Scout",
|
||||
);
|
||||
await expect(page.getByTestId("persona-catalog-details-sheet")).toContainText(
|
||||
"You are Reviewer.",
|
||||
"You are Scout.",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("persona-catalog-detail-selection-title"),
|
||||
).toHaveText("Available in Persona Catalog");
|
||||
await expect(detailSelectionTarget).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Select Reviewer in My Agents",
|
||||
"Select Scout in My Agents",
|
||||
);
|
||||
await expect(detailSelectionTarget).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
@@ -273,11 +267,11 @@ test("catalog details sheet shows the full persona details", async ({
|
||||
).toHaveText("Selected for My Agents");
|
||||
await expect(detailSelectionTarget).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Deselect Reviewer in My Agents",
|
||||
"Deselect Scout in My Agents",
|
||||
);
|
||||
await expect(detailSelectionTarget).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByTestId("agents-library-personas")).toContainText(
|
||||
"Reviewer",
|
||||
"Scout",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -286,13 +280,13 @@ test("inactive built-ins cannot be used to create teams", async ({ page }) => {
|
||||
|
||||
const error = await invokeTauriExpectError(page, "create_team", {
|
||||
input: {
|
||||
name: "Reviewers",
|
||||
personaIds: ["builtin:reviewer"],
|
||||
name: "Scouts",
|
||||
personaIds: ["builtin:scout"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(error).toBe(
|
||||
"Reviewer is not in My Agents. Choose it from Persona Catalog first.",
|
||||
"Scout is not in My Agents. Choose it from Persona Catalog first.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -303,25 +297,21 @@ test("built-in deselection failures show up in Persona Catalog", async ({
|
||||
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByTestId("open-persona-catalog").click();
|
||||
await page
|
||||
.getByTestId("persona-catalog-card-target-builtin:reviewer")
|
||||
.click();
|
||||
await page.getByTestId("persona-catalog-card-target-builtin:scout").click();
|
||||
|
||||
await invokeTauri(page, "create_team", {
|
||||
input: {
|
||||
name: "Reviewers",
|
||||
personaIds: ["builtin:reviewer"],
|
||||
name: "Scouts",
|
||||
personaIds: ["builtin:scout"],
|
||||
},
|
||||
});
|
||||
|
||||
await page
|
||||
.getByTestId("persona-catalog-card-target-builtin:reviewer")
|
||||
.click();
|
||||
await page.getByTestId("persona-catalog-card-target-builtin:scout").click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast]")
|
||||
.filter({ hasText: "Reviewer is still referenced by a team." }),
|
||||
.filter({ hasText: "Scout is still referenced by a team." }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user