feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139
État d'intégration confiné à la branche batch. Les tickets #119 (skills → capacités agent découvrables), #122 (override permissions par défaut), #131 (effort par agent/presets) et #132 (outil MCP d'édition du contexte projet) sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs, backend/dto.rs), inséparable sans staging interactif (indisponible ici). Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée. NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -68,6 +68,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||
"idea_stop_agent",
|
||||
"idea_update_context",
|
||||
"idea_context_propose",
|
||||
"idea_context_update",
|
||||
"idea_memory_write",
|
||||
"idea_workstate_set",
|
||||
"idea_create_skill",
|
||||
@ -166,7 +167,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
name: "idea_list_agents",
|
||||
description: "List the IdeA agents declared in the project's manifest. Returns the \
|
||||
agents inline as a JSON array (id, name, profile, origin, raw skills, \
|
||||
and resolved capabilities).",
|
||||
resolved capabilities, and isOrchestrator).",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@ -308,6 +309,23 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_context_update",
|
||||
description: "Directly update the global project context. Strict, orchestrator-only \
|
||||
counterpart to idea_context_propose: if you are not the project's \
|
||||
current orchestrator (see isOrchestrator on idea_list_agents), this call \
|
||||
fails and never silently files a proposal. Pass ifMatch from a prior \
|
||||
idea_context_read to reject the write if the context changed.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": { "type": "string", "description": "The new Markdown body." },
|
||||
"ifMatch": { "type": "string", "description": "Optional version from a prior idea_context_read; rejected if stale." }
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_memory_read",
|
||||
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
|
||||
@ -485,6 +503,16 @@ pub fn map_tool_call(
|
||||
content: s("content"),
|
||||
..base()
|
||||
},
|
||||
"idea_context_update" => {
|
||||
reject_unknown_keys(name, args, &["content", "ifMatch"])?;
|
||||
OrchestratorRequest {
|
||||
request_type: Some("context.update".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
content: s("content"),
|
||||
if_match: s("ifMatch"),
|
||||
..base()
|
||||
}
|
||||
}
|
||||
"idea_memory_read" => OrchestratorRequest {
|
||||
request_type: Some("memory.read".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
@ -556,6 +584,7 @@ fn base() -> OrchestratorRequest {
|
||||
result: None,
|
||||
ticket: None,
|
||||
content: None,
|
||||
if_match: None,
|
||||
slug: None,
|
||||
status: None,
|
||||
intent: None,
|
||||
@ -569,6 +598,20 @@ fn base() -> OrchestratorRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_unknown_keys(
|
||||
tool: &str,
|
||||
args: &serde_json::Map<String, Value>,
|
||||
allowed: &[&str],
|
||||
) -> Result<(), ToolMapError> {
|
||||
if args
|
||||
.keys()
|
||||
.any(|key| !allowed.iter().any(|allowed_key| allowed_key == key))
|
||||
{
|
||||
return Err(ToolMapError::BadArguments(tool.to_owned()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently
|
||||
/// dropping a malformed/absent one (validation then rejects a `visible` launch
|
||||
/// missing its node, with a precise field error).
|
||||
@ -880,6 +923,51 @@ mod tests {
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_context_update_tool_registered_and_maps_to_context_update_command() {
|
||||
assert_eq!(
|
||||
tool_access("idea_context_update"),
|
||||
Some(McpToolAccess::WriteAction)
|
||||
);
|
||||
assert!(catalogue()
|
||||
.into_iter()
|
||||
.any(|tool| tool.name == "idea_context_update"));
|
||||
|
||||
let cmd = map_tool_call(
|
||||
"idea_context_update",
|
||||
&json!({ "content": "# body", "ifMatch": "abc123" }),
|
||||
REQ,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::UpdateProjectContext {
|
||||
content: "# body".to_owned(),
|
||||
if_match: Some("abc123".to_owned()),
|
||||
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
||||
uuid::Uuid::parse_str(REQ).unwrap()
|
||||
)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_context_update_requires_content_and_rejects_unknown_fields() {
|
||||
let missing = map_tool_call("idea_context_update", &json!({ "ifMatch": "abc123" }), REQ);
|
||||
assert!(matches!(missing, Err(ToolMapError::Invalid(_))));
|
||||
|
||||
let unknown = map_tool_call(
|
||||
"idea_context_update",
|
||||
&json!({ "content": "# body", "target": "Dev" }),
|
||||
REQ,
|
||||
);
|
||||
assert_eq!(
|
||||
unknown,
|
||||
Err(ToolMapError::BadArguments("idea_context_update".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_read_and_write_map_to_their_commands() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user