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

@ -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();