//! Agents author their own skills, with no human in the loop. //! //! The operator's decision. The machinery already existed — `level_up` has //! generated full skill drafts from a model since it shipped — and the only //! thing between propose and apply was an operator ticking a checkbox. //! //! What replaces that checkbox is not another gate but three properties, and //! these tests are what hold them: the write is workspace-scoped and can never //! take a hand-authored skill's name, every change appends a version so it can //! be read back and reverted, and a proposal applied with no human carries no //! human's name in its approval trail. use cm_domain::{Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId}; use serde_json::json; use uuid::Uuid; /// A workspace with one agent. `level_up_target_one` requires a proposal to /// name exactly one of agent_id / team_id, so the agent is not optional here. async fn seed_workspace(pool: &sqlx::PgPool) -> (WorkspaceId, AgentId) { let ws = Workspace { id: WorkspaceId::new(), name: "Self Authoring".into(), plan: "team".into(), }; cm_db::repo::workspaces::insert(pool, &ws).await.unwrap(); let user = User { id: UserId::new(), workspace_id: ws.id, email: format!("owner-{}@example.com", Uuid::now_v7().simple()), role: Role::Owner, display_name: "Owner".into(), created_at: time::OffsetDateTime::UNIX_EPOCH, }; cm_db::repo::users::insert(pool, &user).await.unwrap(); let agent = Agent { id: AgentId::new(), workspace_id: ws.id, name: "Scribe".into(), job_title: "researcher".into(), system_prompt: String::new(), avatar: String::new(), accent: String::new(), wallpaper: String::new(), managed_by: user.id, status: AgentStatus::Online, }; cm_db::repo::agents::insert(pool, &agent, &cm_domain::AccessPolicy::default()) .await .unwrap(); (ws.id, agent.id) } /// A pending proposal carrying one `skill_candidate` draft. async fn seed_proposal( pool: &sqlx::PgPool, ws: WorkspaceId, agent: AgentId, name: &str, body: &str, ) -> Uuid { let payload = json!({ "suggested_items": [{ "id": "item-1", "kind": "skill_candidate", "draft": { "name": name, "description": "a procedure the agent wrote for itself", "when_to_use": "when the situation arises", "body": body, "tags": ["self-authored"], } }] }); cm_db::repo::level_up::insert( pool, cm_db::repo::level_up::NewProposal { workspace_id: ws.as_uuid(), agent_id: Some(agent.as_uuid()), team_id: None, payload: &payload, model: Some("glm:glm-4.7"), created_by: None, }, ) .await .unwrap() } #[tokio::test] async fn an_agent_applies_its_own_skill_with_no_human_and_it_is_versioned() { let pool = cm_testkit::test_pool().await; let (ws, agent) = seed_workspace(&pool).await; let p1 = seed_proposal(&pool, ws, agent, "vault-note-shape", "First: write the date line.").await; let applied = cm_api::level_up::apply_autonomous(&pool, ws, p1) .await .expect("autonomous apply must succeed"); assert_eq!(applied, vec!["item-1".to_string()]); let (id, source_kind, workspace, version): (Uuid, String, Option, i32) = sqlx::query_as( "SELECT id, source_kind, workspace_id, current_version FROM skills WHERE name = $1", ) .bind("vault-note-shape") .fetch_one(&pool) .await .expect("the skill must exist with no human approval"); assert_eq!(source_kind, "promoted_from_brain", "self-authored skills must stay distinguishable from builtins in one query"); assert_eq!(workspace, Some(ws.as_uuid()), "must be workspace-scoped, never global"); assert_eq!(version, 1); // The approval trail must not name a human who did not approve. let approved_by: Option = sqlx::query_scalar("SELECT approved_by FROM level_up_proposals WHERE id = $1") .bind(p1) .fetch_one(&pool) .await .unwrap(); assert!( approved_by.is_none(), "an autonomously applied proposal must record NO approver — putting a \ user id here would attribute a decision to someone who never made it" ); // A revision bumps the version and keeps the old body readable. let p2 = seed_proposal(&pool, ws, agent, "vault-note-shape", "First: write the date line. Then the source.").await; cm_api::level_up::apply_autonomous(&pool, ws, p2).await.unwrap(); let versions: Vec<(i32, String)> = sqlx::query_as("SELECT version, body_md FROM skill_versions WHERE skill_id = $1 ORDER BY version") .bind(id) .fetch_all(&pool) .await .unwrap(); assert_eq!( versions.len(), 2, "each self-authored revision must append a version — without history \ there is no revert, and no way to read back which text a past run was \ actually judged under" ); assert_eq!(versions[0].1, "First: write the date line."); assert!(versions[1].1.contains("Then the source.")); } #[tokio::test] async fn a_draft_cannot_take_a_hand_authored_skills_name() { let pool = cm_testkit::test_pool().await; let (ws, agent) = seed_workspace(&pool).await; // A builtin, as `skills_loader` writes them: global, workspace_id NULL. let builtin = Uuid::now_v7(); sqlx::query( "INSERT INTO skills (id, name, title, author, description, when_to_use, tags, source_kind, workspace_id, current_version, body) VALUES ($1,'arxiv-daily','arxiv-daily','system','the real one','always', '{}','builtin',NULL,1,'Do NOT re-search arXiv.')", ) .bind(builtin) .execute(&pool) .await .unwrap(); let p = seed_proposal(&pool, ws, agent, "arxiv-daily", "Actually, re-searching arXiv is fine.").await; let applied = cm_api::level_up::apply_autonomous(&pool, ws, p).await.unwrap(); assert!( applied.is_empty(), "a draft taking a hand-authored name must be refused: two procedures \ under one name means nobody reading a transcript can tell which the \ agent followed — and this one inverts the rule it shadows" ); let body: String = sqlx::query_scalar("SELECT body FROM skills WHERE id = $1") .bind(builtin) .fetch_one(&pool) .await .unwrap(); assert_eq!( body, "Do NOT re-search arXiv.", "the hand-authored skill must be untouched" ); let n: i64 = sqlx::query_scalar("SELECT count(*) FROM skills WHERE name = 'arxiv-daily'") .fetch_one(&pool) .await .unwrap(); assert_eq!(n, 1, "no second row may exist under that name"); } #[tokio::test] async fn autonomous_apply_leaves_identity_and_memory_items_for_a_human() { let pool = cm_testkit::test_pool().await; let (ws, agent) = seed_workspace(&pool).await; let payload = json!({ "suggested_items": [ { "id": "skill-1", "kind": "skill_candidate", "draft": { "name": "commit-message-shape", "description": "d", "body": "Say what changed and why.", "tags": [] } }, { "id": "identity-1", "kind": "identity_refinement", "new_system_prompt": "You are now a different agent." } ] }); let p = cm_db::repo::level_up::insert( &pool, cm_db::repo::level_up::NewProposal { workspace_id: ws.as_uuid(), agent_id: Some(agent.as_uuid()), team_id: None, payload: &payload, model: Some("glm:glm-4.7"), created_by: None, }, ) .await .unwrap(); let applied = cm_api::level_up::apply_autonomous(&pool, ws, p).await.unwrap(); assert_eq!( applied, vec!["skill-1".to_string()], "only skill_candidate items apply autonomously — an identity rewrite \ changes what the agent IS rather than adding a procedure it can \ consult, and that is a different bet than the one that was taken" ); } #[tokio::test] async fn the_sweep_applies_pending_drafts_and_leaves_nothing_pending_twice() { let pool = cm_testkit::test_pool().await; let (ws, agent) = seed_workspace(&pool).await; seed_proposal(&pool, ws, agent, "swept-skill", "Say what changed and why.").await; let n = cm_api::skill_self_authoring::sweep(&pool).await.unwrap(); assert_eq!(n, 1, "the sweep must apply the pending draft with no human"); let body: String = sqlx::query_scalar("SELECT body FROM skills WHERE name = 'swept-skill'") .fetch_one(&pool) .await .expect("the swept draft must be in the catalogue"); assert_eq!(body, "Say what changed and why."); // Idempotent: the proposal is no longer pending, so a second pass is a // no-op rather than a duplicate apply or a version bump for no change. let again = cm_api::skill_self_authoring::sweep(&pool).await.unwrap(); assert_eq!(again, 0, "a swept proposal must not be applied twice"); let versions: i64 = sqlx::query_scalar("SELECT count(*) FROM skill_versions sv JOIN skills s ON s.id = sv.skill_id WHERE s.name = 'swept-skill'") .fetch_one(&pool) .await .unwrap(); assert_eq!(versions, 1, "an unchanged body must not append a version"); }