feat(orchestrator): create_skill action + wire per-project watchers (§14.3)

- domain: OrchestratorCommand::CreateSkill with optional scope field
  (defaults to Project, case-insensitive, UnknownScope on bad value)
- application: dispatch CreateSkill through the CreateSkill use case
- app-tauri: build OrchestratorService and start/stop per-project request
  watchers on open/create/close_project (ensure/stop_orchestrator_watch)
- tests: domain validation, service dispatch, infra watcher e2e,
  app-tauri wiring lifecycle (idempotent, isolated, stop)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 13:06:41 +02:00
parent b9fd2fb925
commit d11eaaa8c0
13 changed files with 619 additions and 52 deletions

View File

@ -1,56 +1,43 @@
{
"version": 1,
"activeId": "9188db80-8535-4786-a20b-3c9a36b222e3",
"activeId": "eed26045-b208-47ff-98ee-ca0d6e5933b3",
"layouts": [
{
"id": "9188db80-8535-4786-a20b-3c9a36b222e3",
"id": "eed26045-b208-47ff-98ee-ca0d6e5933b3",
"name": "Default",
"kind": "terminal",
"tree": {
"root": {
"type": "split",
"node": {
"id": "8aca2f93-1a9b-4693-9bba-9a01e130a48c",
"id": "8cbe4c7c-357f-49ad-ac20-c96802a84684",
"direction": "row",
"children": [
{
"node": {
"type": "leaf",
"node": {
"id": "d8a86eb1-cd4d-4937-b900-4989da7c868d",
"session": "8a976ad4-72d5-4dfa-9176-b0838e8e7e1d",
"id": "e8933dbd-1c53-4342-96ca-020b9a9b7970",
"session": "51c96008-f875-4515-9b5b-50c4217f64d6",
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641"
}
},
"weight": 0.9225053
"weight": 1.0
},
{
"node": {
"type": "leaf",
"node": {
"id": "6c5be5e7-a54b-468c-a2e2-8ec853629d5e",
"session": "b6ef5e09-411b-4da4-93a7-d32ecd4bb750"
"id": "50da405b-18f3-4273-9fe0-57bb242cb2f7",
"session": "5a796d0e-433e-4ded-9955-ed02b389d9c1"
}
},
"weight": 1.0774947
"weight": 1.0
}
]
}
}
}
},
{
"id": "40ea4fa9-25b3-410b-9d3e-6350834b421b",
"name": "Git Graph",
"kind": "gitGraph",
"tree": {
"root": {
"type": "leaf",
"node": {
"id": "c840bfdd-3330-46a3-b727-0799f6853e72"
}
}
}
}
]
}

View File

@ -658,7 +658,12 @@ IdeA détecte le fichier, exécute la même logique que `LaunchAgent` déclench
**Port `OrchestratorApi`** (adapter entrant, driven by file-watcher) : surveille `.ideai/requests/`, désérialise les commandes, les traduit en appels de use cases (`LaunchAgent`, `StopAgent`…). Implémenté dans `infrastructure/orchestrator`.
**Actions supportées (v1)** : `spawn_agent`, `stop_agent`, `update_agent_context`.
**Actions supportées (v1)** : `spawn_agent`, `stop_agent`, `update_agent_context`, `create_skill`.
`create_skill` permet à un orchestrateur de créer un skill réutilisable exactement comme l'UI (use case `CreateSkill`). Champs : `name`, `context` (corps Markdown du skill), `scope` optionnel (`"global"` | `"project"`, défaut `project`). Exemple :
```json
{ "action": "create_skill", "name": "deploy", "context": "# Étapes de déploiement…", "scope": "project" }
```
---

View File

@ -68,12 +68,15 @@ pub async fn create_project(
request: CreateProjectRequestDto,
state: State<'_, AppState>,
) -> Result<ProjectDto, ErrorDto> {
state
let output = state
.create_project
.execute(request.into())
.await
.map(ProjectDto::from)
.map_err(ErrorDto::from)
.map_err(ErrorDto::from)?;
// Start tailing this project's `.ideai/requests/` tree so an orchestrator
// agent can delegate agent/skill creation to IdeA (§14.3).
state.ensure_orchestrator_watch(&output.project);
Ok(ProjectDto::from(output))
}
/// `open_project` — load a project and its `.ideai/` meta/manifest.
@ -87,12 +90,14 @@ pub async fn open_project(
state: State<'_, AppState>,
) -> Result<ProjectDto, ErrorDto> {
let id = parse_project_id(&project_id)?;
state
let output = state
.open_project
.execute(OpenProjectInput { project_id: id })
.await
.map(ProjectDto::from)
.map_err(ErrorDto::from)
.map_err(ErrorDto::from)?;
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
state.ensure_orchestrator_watch(&output.project);
Ok(ProjectDto::from(output))
}
/// `close_project` — persist state and release resources for a project.
@ -105,6 +110,8 @@ pub async fn close_project(
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let id = parse_project_id(&project_id)?;
// Stop tailing this project's `.ideai/requests/` tree (§14.3).
state.stop_orchestrator_watch(&id);
state
.close_project
.execute(CloseProjectInput {

View File

@ -5,8 +5,9 @@
//! `Arc<dyn Port>` into the use cases (ARCHITECTURE §1.1, §10). The use cases
//! are then exposed through `tauri::State<AppState>` to the command handlers.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use application::{
CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, CreateAgentFromScratch,
@ -15,7 +16,8 @@ use application::{
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListSkills,
ListTemplates, LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal,
ReadAgentContext, ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout,
OrchestratorService, ReadAgentContext, ReferenceProfiles, RenameLayout, ResizeTerminal,
SaveProfile, SetActiveLayout,
AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill,
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
};
@ -23,11 +25,12 @@ use domain::ports::{
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore,
};
use domain::{DomainEvent, Project, ProjectId};
use infrastructure::{
CliAgentRuntime, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository,
IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock,
TokioBroadcastEventBus, UuidGenerator,
CliAgentRuntime, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore,
FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner,
OrchestratorWatchHandle, PortablePtyAdapter, SystemClock, TokioBroadcastEventBus, UuidGenerator,
};
use crate::pty::PtyBridge;
@ -161,6 +164,15 @@ pub struct AppState {
pub assign_skill: Arc<AssignSkillToAgent>,
/// Unassign a skill from an agent.
pub unassign_skill: Arc<UnassignSkillFromAgent>,
// --- Orchestrator (§14.3) ---
/// Dispatches validated orchestrator requests to the agent/skill use cases.
/// Shared by every per-project filesystem watcher.
pub orchestrator_service: Arc<OrchestratorService>,
/// Live orchestrator request watchers, keyed by project. One watcher per open
/// project tails its `.ideai/requests/` tree; dropping the handle stops it.
/// Guarded by a `Mutex` so the open/close commands can register/unregister
/// watchers concurrently.
pub orchestrator_watchers: Mutex<HashMap<ProjectId, OrchestratorWatchHandle>>,
}
impl AppState {
@ -409,6 +421,22 @@ impl AppState {
Arc::clone(&events_port),
));
// --- Orchestrator service (§14.3) ---
// Dispatches file-based orchestrator requests through the *same* use cases
// the UI drives (IdeA stays the single source of truth for the agent/skill
// lifecycle). The per-project watcher that feeds it is started lazily when
// a project is opened (see `ensure_orchestrator_watch`).
let orchestrator_service = Arc::new(OrchestratorService::new(
Arc::clone(&create_agent),
Arc::clone(&launch_agent),
Arc::clone(&list_agents),
Arc::clone(&close_terminal),
Arc::clone(&update_agent_context),
Arc::clone(&create_skill),
Arc::clone(&profile_store_port),
Arc::clone(&terminal_sessions),
));
// --- Windows (L10) ---
let move_tab = Arc::new(MoveTabToNewWindow::new(
Arc::clone(&store_port),
@ -473,7 +501,50 @@ impl AppState {
delete_skill,
assign_skill,
unassign_skill,
orchestrator_service,
orchestrator_watchers: Mutex::new(HashMap::new()),
move_tab,
}
}
/// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already
/// running for it (idempotent). The watcher tails the project's
/// `.ideai/requests/` tree and dispatches each request through the shared
/// [`OrchestratorService`]; processed-request events are republished on the
/// domain event bus so the relay surfaces them to the frontend.
///
/// Called from the `open_project` / `create_project` commands. Must run inside
/// the Tokio runtime (the watcher spawns a background task) — Tauri async
/// commands satisfy this.
pub fn ensure_orchestrator_watch(&self, project: &Project) {
let mut watchers = self
.orchestrator_watchers
.lock()
.expect("orchestrator watcher registry poisoned");
if watchers.contains_key(&project.id) {
return;
}
let bus = Arc::clone(&self.event_bus);
let events: Arc<dyn Fn(DomainEvent) + Send + Sync> =
Arc::new(move |event| bus.publish(event));
let handle = FsOrchestratorWatcher::start(
project.clone(),
Arc::clone(&self.orchestrator_service),
events,
);
watchers.insert(project.id, handle);
}
/// Stops and removes the orchestrator watcher for `project_id`, if any.
/// Called from `close_project` so a closed project stops consuming requests.
pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) {
if let Some(handle) = self
.orchestrator_watchers
.lock()
.expect("orchestrator watcher registry poisoned")
.remove(project_id)
{
handle.stop();
}
}
}

View File

@ -0,0 +1,100 @@
//! Integration test for the orchestrator wiring in the composition root
//! (ARCHITECTURE §14.3).
//!
//! These tests prove that [`AppState`] actually *starts and stops* per-project
//! orchestrator watchers — the gap that previously left the whole §14.3 feature
//! dormant (the `OrchestratorService`/watcher existed but were never constructed
//! at runtime). The per-file request→dispatch→response behaviour is covered by
//! the infrastructure watcher tests; here we assert the lifecycle the open/close
//! commands rely on: registration is idempotent, projects are isolated, and
//! stopping unregisters.
use std::path::PathBuf;
use app_tauri_lib::state::AppState;
use domain::ports::IdGenerator;
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::ProjectId;
use infrastructure::UuidGenerator;
/// A unique, absolute temp path (never written to at build time — the stores are
/// lazy — so it need not exist).
fn temp_path(tag: &str) -> PathBuf {
let ids = UuidGenerator::new();
std::env::temp_dir().join(format!("idea-orch-test-{tag}-{}", ids.new_uuid()))
}
/// Builds a domain [`Project`] rooted at a fresh temp path.
fn make_project() -> Project {
let ids = UuidGenerator::new();
let root = temp_path("root");
Project::new(
ProjectId::from_uuid(ids.new_uuid()),
"demo",
ProjectPath::new(root.to_string_lossy().into_owned()).unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
fn watcher_count(state: &AppState) -> usize {
state.orchestrator_watchers.lock().unwrap().len()
}
fn has_watcher(state: &AppState, id: &ProjectId) -> bool {
state.orchestrator_watchers.lock().unwrap().contains_key(id)
}
#[tokio::test]
async fn ensure_watch_registers_a_watcher_and_is_idempotent() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
assert_eq!(watcher_count(&state), 0, "no watcher before open");
state.ensure_orchestrator_watch(&project);
assert!(has_watcher(&state, &project.id));
assert_eq!(watcher_count(&state), 1);
// Opening the same project again must not spawn a second watcher.
state.ensure_orchestrator_watch(&project);
assert_eq!(watcher_count(&state), 1, "ensure is idempotent per project");
}
#[tokio::test]
async fn stop_watch_unregisters_the_watcher() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
assert!(has_watcher(&state, &project.id));
state.stop_orchestrator_watch(&project.id);
assert!(!has_watcher(&state, &project.id), "watcher removed on close");
assert_eq!(watcher_count(&state), 0);
// Stopping an unknown project is a no-op (does not panic).
state.stop_orchestrator_watch(&project.id);
}
#[tokio::test]
async fn watchers_are_isolated_per_project() {
let state = AppState::build(temp_path("appdata"));
let a = make_project();
let b = make_project();
state.ensure_orchestrator_watch(&a);
state.ensure_orchestrator_watch(&b);
assert_eq!(watcher_count(&state), 2);
assert!(has_watcher(&state, &a.id));
assert!(has_watcher(&state, &b.id));
// Closing one leaves the other running.
state.stop_orchestrator_watch(&a.id);
assert!(!has_watcher(&state, &a.id));
assert!(has_watcher(&state, &b.id));
assert_eq!(watcher_count(&state), 1);
}

View File

@ -23,6 +23,7 @@ use crate::agent::{
ListAgentsInput, UpdateAgentContext, UpdateAgentContextInput,
};
use crate::error::AppError;
use crate::skill::{CreateSkill, CreateSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions};
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
@ -39,6 +40,7 @@ pub struct OrchestratorService {
list_agents: Arc<ListAgents>,
close_terminal: Arc<CloseTerminal>,
update_context: Arc<UpdateAgentContext>,
create_skill: Arc<CreateSkill>,
profiles: Arc<dyn ProfileStore>,
sessions: Arc<TerminalSessions>,
}
@ -61,6 +63,7 @@ impl OrchestratorService {
list_agents: Arc<ListAgents>,
close_terminal: Arc<CloseTerminal>,
update_context: Arc<UpdateAgentContext>,
create_skill: Arc<CreateSkill>,
profiles: Arc<dyn ProfileStore>,
sessions: Arc<TerminalSessions>,
) -> Self {
@ -70,6 +73,7 @@ impl OrchestratorService {
list_agents,
close_terminal,
update_context,
create_skill,
profiles,
sessions,
}
@ -96,6 +100,11 @@ impl OrchestratorService {
OrchestratorCommand::UpdateAgentContext { name, context } => {
self.update_agent_context(project, name, context).await
}
OrchestratorCommand::CreateSkill {
name,
content,
scope,
} => self.create_skill(project, name, content, scope).await,
}
}
@ -193,6 +202,32 @@ impl OrchestratorService {
})
}
/// `create_skill`: create a reusable skill in the requested scope — the same
/// path the UI's "New skill" action takes. For [`SkillScope::Project`] the
/// skill lands under `<root>/.ideai/skills/`; for [`SkillScope::Global`] the
/// project root is ignored by the store.
async fn create_skill(
&self,
project: &Project,
name: String,
content: String,
scope: domain::SkillScope,
) -> Result<OrchestratorOutcome, AppError> {
let created = self
.create_skill
.execute(CreateSkillInput {
name: name.clone(),
content,
scope,
project_root: project.root.clone(),
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("created skill {} ({:?})", created.skill.name, scope),
})
}
/// Finds an agent id by display name (case-insensitive) in the project manifest.
async fn find_agent_id_by_name(
&self,

View File

@ -33,8 +33,8 @@ use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, LaunchAgent, ListAgents, OrchestratorService,
TerminalSessions, UpdateAgentContext,
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
// ---------------------------------------------------------------------------
@ -183,6 +183,44 @@ impl SkillStore for FakeSkills {
}
}
/// A [`SkillStore`] that records every `save` so a `create_skill` dispatch can be
/// asserted against the persisted skill (name + scope).
#[derive(Clone, Default)]
struct RecordingSkills(Arc<Mutex<Vec<Skill>>>);
#[async_trait]
impl SkillStore for RecordingSkills {
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(
&self,
_scope: SkillScope,
_root: &ProjectPath,
id: SkillId,
) -> Result<Skill, StoreError> {
self.0
.lock()
.unwrap()
.iter()
.find(|s| s.id == id)
.cloned()
.ok_or(StoreError::NotFound)
}
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
self.0.lock().unwrap().push(skill.clone());
Ok(())
}
async fn delete(
&self,
_scope: SkillScope,
_root: &ProjectPath,
_id: SkillId,
) -> Result<(), StoreError> {
Ok(())
}
}
struct FakeRuntime;
impl AgentRuntime for FakeRuntime {
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
@ -349,6 +387,7 @@ struct Fixture {
pty: FakePty,
bus: SpyBus,
sessions: Arc<TerminalSessions>,
skills: RecordingSkills,
}
fn fixture(contexts: FakeContexts) -> Fixture {
@ -378,6 +417,11 @@ fn fixture(contexts: FakeContexts) -> Fixture {
Arc::clone(&sessions),
));
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
let skills = RecordingSkills::default();
let create_skill = Arc::new(CreateSkill::new(
Arc::new(skills.clone()) as Arc<dyn SkillStore>,
Arc::new(SeqIds::new()),
));
let service = OrchestratorService::new(
create,
@ -385,6 +429,7 @@ fn fixture(contexts: FakeContexts) -> Fixture {
list,
close,
update,
create_skill,
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::clone(&sessions),
);
@ -395,6 +440,7 @@ fn fixture(contexts: FakeContexts) -> Fixture {
pty,
bus,
sessions,
skills,
}
}
@ -541,3 +587,43 @@ async fn spawn_with_unknown_profile_is_not_found_and_does_not_create() {
assert!(fx.contexts.manifest().entries.is_empty());
assert!(fx.sessions.session(&sid(777)).is_none());
}
#[tokio::test]
async fn create_skill_persists_a_project_scoped_skill_by_default() {
let fx = fixture(FakeContexts::new());
let out = fx
.service
.dispatch(
&project(),
cmd(r##"{ "action":"create_skill", "name":"deploy", "context":"# Deploy" }"##),
)
.await
.expect("create_skill ok");
assert!(out.detail.contains("deploy"));
let saved = fx.skills.0.lock().unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].name, "deploy");
assert_eq!(saved[0].scope, SkillScope::Project);
}
#[tokio::test]
async fn create_skill_honours_global_scope() {
let fx = fixture(FakeContexts::new());
fx.service
.dispatch(
&project(),
cmd(
r##"{ "action":"create_skill", "name":"shared", "context":"# Shared", "scope":"global" }"##,
),
)
.await
.expect("create_skill ok");
let saved = fx.skills.0.lock().unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].scope, SkillScope::Global);
}

View File

@ -13,6 +13,8 @@
use serde::{Deserialize, Serialize};
use crate::skill::SkillScope;
/// Errors raised while validating a raw [`OrchestratorRequest`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OrchestratorError {
@ -27,6 +29,14 @@ pub enum OrchestratorError {
/// The required field that was absent or empty.
field: String,
},
/// The `scope` field is present but not a recognised skill scope.
#[error("unknown skill scope `{scope}` for action `{action}`")]
UnknownScope {
/// The action being validated.
action: String,
/// The offending scope value.
scope: String,
},
}
/// The raw, wire-level orchestrator request as deserialised from a request file.
@ -47,10 +57,15 @@ pub struct OrchestratorRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
/// Context reference: for `spawn_agent` the relative `.md` path is informative
/// (the manifest owns the real path); for `update_agent_context` this carries
/// the **new Markdown body** to write.
/// (the manifest owns the real path); for `update_agent_context` **and**
/// `create_skill` this carries the **new Markdown body** to write.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
/// Skill scope for `create_skill` (`"global"` or `"project"`, case-insensitive).
/// Optional — absent/empty defaults to [`SkillScope::Project`]. Ignored by the
/// other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
/// A validated orchestrator command — the only thing the application layer acts on.
@ -81,6 +96,15 @@ pub enum OrchestratorCommand {
/// New Markdown body.
context: String,
},
/// Create a reusable skill in the given scope — exactly as the UI would.
CreateSkill {
/// Display name (also the `.md` stem on disk).
name: String,
/// Markdown body of the skill.
content: String,
/// Scope the skill is created in (defaults to [`SkillScope::Project`]).
scope: SkillScope,
},
}
impl OrchestratorRequest {
@ -114,10 +138,33 @@ impl OrchestratorRequest {
name: self.require_name(action)?,
context: self.require("context", action, self.context.as_deref())?,
}),
"create_skill" => Ok(OrchestratorCommand::CreateSkill {
name: self.require_name(action)?,
content: self.require("context", action, self.context.as_deref())?,
scope: self.parse_scope(action)?,
}),
other => Err(OrchestratorError::UnknownAction(other.to_owned())),
}
}
/// Parses the optional `scope` field into a [`SkillScope`], defaulting to
/// [`SkillScope::Project`] when absent or empty.
///
/// # Errors
/// [`OrchestratorError::UnknownScope`] when the value is neither `global` nor
/// `project` (case-insensitive).
fn parse_scope(&self, action: &str) -> Result<SkillScope, OrchestratorError> {
match self.scope.as_deref().map(str::trim) {
None | Some("") => Ok(SkillScope::Project),
Some(s) if s.eq_ignore_ascii_case("project") => Ok(SkillScope::Project),
Some(s) if s.eq_ignore_ascii_case("global") => Ok(SkillScope::Global),
Some(other) => Err(OrchestratorError::UnknownScope {
action: action.to_owned(),
scope: other.to_owned(),
}),
}
}
/// Requires a non-empty `name`, shared by all actions.
fn require_name(&self, action: &str) -> Result<String, OrchestratorError> {
self.require("name", action, self.name.as_deref())
@ -235,6 +282,62 @@ mod tests {
);
}
#[test]
fn create_skill_defaults_to_project_scope() {
let r = req(
r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##,
);
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::CreateSkill {
name: "deploy".to_owned(),
content: "# Deploy steps".to_owned(),
scope: SkillScope::Project,
}
);
}
#[test]
fn create_skill_honours_explicit_scope_case_insensitively() {
let r = req(
r##"{ "action": "create_skill", "name": "deploy", "context": "body", "scope": "Global" }"##,
);
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::CreateSkill {
name: "deploy".to_owned(),
content: "body".to_owned(),
scope: SkillScope::Global,
}
);
}
#[test]
fn create_skill_missing_body_is_rejected() {
let r = req(r#"{ "action": "create_skill", "name": "deploy" }"#);
assert_eq!(
r.validate(),
Err(OrchestratorError::MissingField {
action: "create_skill".to_owned(),
field: "context".to_owned(),
})
);
}
#[test]
fn create_skill_unknown_scope_is_rejected() {
let r = req(
r##"{ "action": "create_skill", "name": "deploy", "context": "body", "scope": "team" }"##,
);
assert_eq!(
r.validate(),
Err(OrchestratorError::UnknownScope {
action: "create_skill".to_owned(),
scope: "team".to_owned(),
})
);
}
#[test]
fn unknown_action_is_rejected() {
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);

View File

@ -32,8 +32,8 @@ use domain::{PtySize, SessionId};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, LaunchAgent, ListAgents, OrchestratorService,
TerminalSessions, UpdateAgentContext,
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{process_request_file, OrchestratorResponse};
@ -305,12 +305,17 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
let create_skill = Arc::new(CreateSkill::new(
Arc::new(FakeSkills) as Arc<dyn SkillStore>,
Arc::new(SeqIds(Mutex::new(1))),
));
Arc::new(OrchestratorService::new(
create,
launch,
list,
close,
update,
create_skill,
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
sessions,
))
@ -350,6 +355,27 @@ async fn valid_spawn_request_succeeds_and_is_consumed() {
assert_eq!(contexts.entries()[0].name, "dev-backend");
}
#[tokio::test]
async fn valid_create_skill_request_succeeds_and_is_consumed() {
let tmp = TempDir::new();
let service = build_service(FakeContexts::new());
let req = tmp.0.join("skill-req.json");
std::fs::write(
&req,
br##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##,
)
.unwrap();
let response = process_request_file(&req, &project(), &service).await;
assert!(response.ok, "expected ok, got {response:?}");
assert_eq!(response.action.as_deref(), Some("create_skill"));
// Request consumed; a response sibling written and marked ok.
assert!(!req.exists(), "request file must be removed");
assert!(read_response(&req).ok);
}
#[tokio::test]
async fn invalid_json_request_yields_error_response() {
let tmp = TempDir::new();

View File

@ -19,7 +19,12 @@ import {
fireEvent,
} from "@testing-library/react";
import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock";
import {
MockAgentGateway,
MockProfileGateway,
MockSystemGateway,
MockTemplateGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { AgentsPanel } from "./AgentsPanel";
@ -384,3 +389,45 @@ describe("MockAgentGateway (unit)", () => {
expect(listB).toHaveLength(0);
});
});
describe("AgentsPanel live refresh on domain events", () => {
it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
const agent = new MockAgentGateway();
const profile = new MockProfileGateway();
const system = new MockSystemGateway();
const template = new MockTemplateGateway(agent);
const gateways = { agent, profile, system, template } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
</DIProvider>,
);
// Initially empty.
await waitFor(() => expect(screen.getByText("No agents yet.")).toBeTruthy());
// Simulate the orchestrator creating an agent directly in the store
// (bypassing the UI's createAgent path), then emitting `agentLaunched`.
const created = await agent.createAgent(PROJECT_ID, {
name: "Orchestrated",
profileId: "p1",
});
system.emit({
type: "agentLaunched",
agentId: created.id,
sessionId: "sess-1",
});
// The panel must reflect the new agent without any manual reload.
expect(await screen.findByText("Orchestrated")).toBeTruthy();
});
it("does not crash when the system gateway is absent", async () => {
// The existing renderPanel helper injects no `system` gateway; the
// subscription effect must no-op rather than throw.
renderPanel();
await waitForIdle();
expect(screen.getByText("No agents yet.")).toBeTruthy();
});
});

View File

@ -64,7 +64,7 @@ function describe(e: unknown): string {
}
export function useAgents(projectId: string): AgentsViewModel {
const { agent, profile } = useGateways();
const { agent, profile, system } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
@ -95,6 +95,32 @@ export function useAgents(projectId: string): AgentsViewModel {
void refresh();
}, [refresh]);
// Live refresh: the agents list is otherwise only reloaded on mount or after a
// UI-driven CRUD. An agent created/launched/stopped *out of band* — most
// notably by the orchestrator (`spawn_agent`, ARCHITECTURE §14.3) when an AI
// delegates agent creation to IdeA — emits `agentLaunched` / `agentExited`.
// Subscribing here makes the tab reflect those changes without a manual reload.
// `system` may be absent in unit tests that inject a partial gateway set; guard.
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (event.type === "agentLaunched" || event.type === "agentExited") {
void refresh();
}
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [system, refresh]);
const createAgent = useCallback(
async (name: string, profileId: string, initialContent?: string) => {
setBusy(true);

View File

@ -9,7 +9,7 @@
* bailed.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockLayoutGateway, MockTerminalGateway } from "@/adapters/mock";
@ -90,6 +90,72 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
});
});
it("closing a cell removes THAT cell and keeps its sibling (closes the right terminal)", async () => {
const layout = new MockLayoutGateway();
const initial = await layout.loadLayout("p1");
const a = leaves(initial)[0].id;
// Split A → container c with children [A (index 0), b (index 1)].
await layout.mutateLayout("p1", {
type: "split",
target: a,
direction: "row",
newLeaf: "b",
container: "c",
});
renderGrid(layout);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2),
);
// Click the close button on cell `b` — `b` must disappear, `a` must remain.
fireEvent.click(screen.getByLabelText("close b"));
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
);
expect(
screen.getByTestId("layout-leaf").getAttribute("data-node-id"),
).toBe(a);
});
it("closing the other cell keeps the opposite sibling", async () => {
const layout = new MockLayoutGateway();
const initial = await layout.loadLayout("p1");
const a = leaves(initial)[0].id;
await layout.mutateLayout("p1", {
type: "split",
target: a,
direction: "row",
newLeaf: "b",
container: "c",
});
renderGrid(layout);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2),
);
// Closing `a` (index 0) must keep `b` (index 1).
fireEvent.click(screen.getByLabelText(`close ${a}`));
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
);
expect(
screen.getByTestId("layout-leaf").getAttribute("data-node-id"),
).toBe("b");
});
it("the sole root cell has no close button (cannot be closed)", async () => {
const layout = new MockLayoutGateway();
renderGrid(layout);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
);
expect(screen.queryByLabelText(/^close /)).toBeNull();
});
it("renders the loading/empty container when the layout gateway is absent", () => {
// A DI subset without the layout gateway → useLayout is defensive (null).
const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways;

View File

@ -113,7 +113,14 @@ interface LeafViewProps {
}
function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
const canMerge = parentSplit !== null && parentSplit.siblings > 1;
// A cell can be closed only when it lives inside a (binary) split: closing it
// collapses the parent split, keeping the *sibling*. Splits are always binary
// in this model (a split wraps a leaf into a 2-child container), so the kept
// child is simply the other index. Guarding on `siblings === 2` keeps the
// operation correct (merge keeps exactly one child) and avoids ever dropping
// the wrong terminal. The root cell (no parent split) cannot be closed.
const canClose = parentSplit !== null && parentSplit.siblings === 2;
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
const { agent: agentGateway } = useGateways();
// Load the project's agents for the dropdown.
@ -159,7 +166,10 @@ function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafV
style={{
position: "absolute",
top: 2,
right: 2,
// Clear the xterm viewport scrollbar (rendered flush against the right
// edge, ~15px wide). Without this offset the right-most control — the
// close button — sits *behind* the scrollbar and is hard to click.
right: 16,
zIndex: 2,
display: "flex",
gap: 2,
@ -208,16 +218,14 @@ function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafV
>
</button>
{canMerge && (
{canClose && (
<button
type="button"
title="Merge: keep this cell, drop its siblings"
aria-label={`merge ${id}`}
onClick={() =>
void vm.merge(parentSplit.container, parentSplit.index)
}
title="Close this terminal"
aria-label={`close ${id}`}
onClick={() => void vm.merge(parentSplit.container, siblingIndex)}
>
</button>
)}
</div>