feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -106,6 +106,25 @@ pub enum DomainEvent {
|
||||
/// The project whose index was rebuilt.
|
||||
project_id: ProjectId,
|
||||
},
|
||||
/// A project's memory grew past the recall budget while no embedder is
|
||||
/// configured (strategy `none`): the first moment a semantic embedder would
|
||||
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per
|
||||
/// project** (and never again once the user chose "ne plus demander"); the
|
||||
/// frontend surfaces a one-time, dismissible suggestion. Carries the detected
|
||||
/// local environment and the compiled-in capabilities so the UI never proposes
|
||||
/// a strategy this binary cannot run.
|
||||
EmbedderSuggested {
|
||||
/// The project whose memory crossed the budget.
|
||||
project_id: ProjectId,
|
||||
/// Whether an Ollama-style local embedding server was detected (best-effort).
|
||||
ollama_detected: bool,
|
||||
/// Ids of the recommended ONNX models already present in the local cache.
|
||||
onnx_cached: Vec<String>,
|
||||
/// Whether the HTTP capability (`localServer`/`api`) is compiled in.
|
||||
vector_http_enabled: bool,
|
||||
/// Whether the in-process ONNX capability (`localOnnx`) is compiled in.
|
||||
vector_onnx_enabled: bool,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
|
||||
@ -429,6 +429,57 @@ impl LayoutTree {
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Attaches an existing live [`SessionId`] to `target`, moving it away from
|
||||
/// any other visible leaf first.
|
||||
///
|
||||
/// This models the IdeA-first visible/background contract: a live agent
|
||||
/// session may keep running while detached from the grid, and opening a cell
|
||||
/// on that already-running session simply re-attaches its view. The session is
|
||||
/// visible in at most one cell because any previous host is cleared before the
|
||||
/// target is set.
|
||||
///
|
||||
/// Pure: returns a new validated tree.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree,
|
||||
/// - [`LayoutError::CrossContainer`] if `target` hosts a different session.
|
||||
pub fn attach_session(&self, target: NodeId, session: SessionId) -> Result<Self, LayoutError> {
|
||||
let target_session = self.session_in_leaf(target)?;
|
||||
if let Some(existing) = target_session {
|
||||
if existing == session {
|
||||
return Ok(self.clone());
|
||||
}
|
||||
return Err(LayoutError::CrossContainer);
|
||||
}
|
||||
|
||||
let root = map_node(&self.root, &mut |node| {
|
||||
if let LayoutNode::Leaf(leaf) = node {
|
||||
if leaf.session == Some(session) && leaf.id != target {
|
||||
return LayoutNode::Leaf(LeafCell {
|
||||
id: leaf.id,
|
||||
session: None,
|
||||
agent: leaf.agent,
|
||||
conversation_id: leaf.conversation_id.clone(),
|
||||
agent_was_running: leaf.agent_was_running,
|
||||
});
|
||||
}
|
||||
if leaf.id == target {
|
||||
return LayoutNode::Leaf(LeafCell {
|
||||
id: leaf.id,
|
||||
session: Some(session),
|
||||
agent: leaf.agent,
|
||||
conversation_id: leaf.conversation_id.clone(),
|
||||
agent_was_running: leaf.agent_was_running,
|
||||
});
|
||||
}
|
||||
}
|
||||
node.clone()
|
||||
});
|
||||
let tree = Self { root };
|
||||
tree.validate()?;
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Attaches (or, with `None`, detaches) an [`AgentId`] to the leaf `target`.
|
||||
///
|
||||
/// Records which agent should be auto-launched in the cell (feature #3).
|
||||
@ -513,11 +564,7 @@ impl LayoutTree {
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
|
||||
pub fn set_agent_running(
|
||||
&self,
|
||||
target: NodeId,
|
||||
running: bool,
|
||||
) -> Result<Self, LayoutError> {
|
||||
pub fn set_agent_running(&self, target: NodeId, running: bool) -> Result<Self, LayoutError> {
|
||||
let mut found = false;
|
||||
let root = map_node(&self.root, &mut |node| {
|
||||
if let LayoutNode::Leaf(leaf) = node {
|
||||
@ -580,9 +627,7 @@ impl LayoutTree {
|
||||
match node {
|
||||
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::Split(split) => {
|
||||
split.children.iter().find_map(|c| find(&c.node, id))
|
||||
}
|
||||
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
||||
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,9 +74,7 @@ pub use profile::{
|
||||
|
||||
pub use markdown::MarkdownDoc;
|
||||
|
||||
pub use memory::{
|
||||
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
|
||||
};
|
||||
pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};
|
||||
|
||||
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
||||
|
||||
@ -91,14 +89,16 @@ pub use layout::{
|
||||
|
||||
pub use events::DomainEvent;
|
||||
|
||||
pub use orchestrator::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
|
||||
pub use orchestrator::{
|
||||
OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility,
|
||||
};
|
||||
|
||||
pub use ports::{
|
||||
AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, Embedder,
|
||||
EmbedderError, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
|
||||
IdGenerator, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream,
|
||||
PreparedContext, ProcessError,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError,
|
||||
RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
|
||||
EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
|
||||
EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
|
||||
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
||||
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PreparedContext,
|
||||
ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
|
||||
RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
|
||||
};
|
||||
|
||||
@ -36,9 +36,7 @@ impl MemorySlug {
|
||||
/// outside `[a-z0-9-]`.
|
||||
pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let raw = raw.into();
|
||||
let invalid = || DomainError::InvalidSlug {
|
||||
value: raw.clone(),
|
||||
};
|
||||
let invalid = || DomainError::InvalidSlug { value: raw.clone() };
|
||||
if raw.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
|
||||
@ -13,12 +13,13 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::NodeId;
|
||||
use crate::skill::SkillScope;
|
||||
|
||||
/// Errors raised while validating a raw [`OrchestratorRequest`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum OrchestratorError {
|
||||
/// The `action` field is not one of the supported v1 actions.
|
||||
/// The requested action/type is not one of the supported actions.
|
||||
#[error("unknown orchestrator action: {0}")]
|
||||
UnknownAction(String),
|
||||
/// A field required by the chosen action is missing or empty.
|
||||
@ -37,6 +38,14 @@ pub enum OrchestratorError {
|
||||
/// The offending scope value.
|
||||
scope: String,
|
||||
},
|
||||
/// The `visibility` field is present but not recognised.
|
||||
#[error("unknown visibility `{visibility}` for action `{action}`")]
|
||||
UnknownVisibility {
|
||||
/// The action being validated.
|
||||
action: String,
|
||||
/// The offending visibility value.
|
||||
visibility: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The raw, wire-level orchestrator request as deserialised from a request file.
|
||||
@ -48,11 +57,22 @@ pub enum OrchestratorError {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OrchestratorRequest {
|
||||
/// The requested action (`spawn_agent`, `stop_agent`, `update_agent_context`).
|
||||
pub action: String,
|
||||
/// Legacy v1 action (`spawn_agent`, `stop_agent`, `update_agent_context`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub action: Option<String>,
|
||||
/// V2 action type (`agent.run`, `agent.stop`, `agent.update_context`,
|
||||
/// `skill.create`). `type` is reserved in Rust, so the field is renamed.
|
||||
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub request_type: Option<String>,
|
||||
/// Optional requester id/name, informational at this layer.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub requested_by: Option<String>,
|
||||
/// Target agent display name (required by every v1 action).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// V2 target agent display name (`agent.run`/`agent.stop`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_agent: Option<String>,
|
||||
/// Runtime profile slug/name (required by `spawn_agent`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
@ -61,6 +81,21 @@ pub struct OrchestratorRequest {
|
||||
/// `create_skill` this carries the **new Markdown body** to write.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<String>,
|
||||
/// Optional task/message carried by `agent.run` / future `agent.message`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task: Option<String>,
|
||||
/// Whether a spawned agent should stay in the background or attach to a cell.
|
||||
///
|
||||
/// Accepted values are `"background"` (default) and `"visible"`. A visible
|
||||
/// spawn must also provide [`Self::node_id`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visibility: Option<String>,
|
||||
/// Target layout leaf for `visibility: "visible"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<NodeId>,
|
||||
/// V2 visible-cell target (`attachToCell`), equivalent to `nodeId`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attach_to_cell: Option<NodeId>,
|
||||
/// Skill scope for `create_skill` (`"global"` or `"project"`, case-insensitive).
|
||||
/// Optional — absent/empty defaults to [`SkillScope::Project`]. Ignored by the
|
||||
/// other actions.
|
||||
@ -79,10 +114,14 @@ pub enum OrchestratorCommand {
|
||||
SpawnAgent {
|
||||
/// Target agent display name.
|
||||
name: String,
|
||||
/// Profile slug/name to resolve against the configured profiles.
|
||||
profile: String,
|
||||
/// Profile slug/name to resolve against the configured profiles. Required
|
||||
/// for legacy `spawn_agent`; optional for `agent.run` when the agent
|
||||
/// already exists and its manifest owns the profile id.
|
||||
profile: Option<String>,
|
||||
/// Optional initial `.md` body for a freshly-created agent.
|
||||
context: Option<String>,
|
||||
/// Desired visibility for the launched session.
|
||||
visibility: OrchestratorVisibility,
|
||||
},
|
||||
/// Stop a running agent by killing its terminal session.
|
||||
StopAgent {
|
||||
@ -107,6 +146,18 @@ pub enum OrchestratorCommand {
|
||||
},
|
||||
}
|
||||
|
||||
/// Where IdeA should place a launched agent session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OrchestratorVisibility {
|
||||
/// Launch or keep running without attaching to a layout cell.
|
||||
Background,
|
||||
/// Launch or re-attach visibly in one layout leaf.
|
||||
Visible {
|
||||
/// Target layout leaf.
|
||||
node_id: NodeId,
|
||||
},
|
||||
}
|
||||
|
||||
impl OrchestratorRequest {
|
||||
/// Validates the raw request into a well-formed [`OrchestratorCommand`].
|
||||
///
|
||||
@ -120,25 +171,44 @@ impl OrchestratorRequest {
|
||||
/// [`OrchestratorError::UnknownAction`] for an unsupported action;
|
||||
/// [`OrchestratorError::MissingField`] when a required field is absent/empty.
|
||||
pub fn validate(&self) -> Result<OrchestratorCommand, OrchestratorError> {
|
||||
let action = self.action.trim();
|
||||
let action = self.action_name()?;
|
||||
match action {
|
||||
"spawn_agent" => Ok(OrchestratorCommand::SpawnAgent {
|
||||
name: self.require_name(action)?,
|
||||
profile: self.require("profile", action, self.profile.as_deref())?,
|
||||
profile: Some(self.require("profile", action, self.profile.as_deref())?),
|
||||
context: self.context.as_ref().filter(|c| !c.is_empty()).cloned(),
|
||||
visibility: self.parse_visibility(action)?,
|
||||
}),
|
||||
"agent.run" => Ok(OrchestratorCommand::SpawnAgent {
|
||||
name: self.require_target_agent(action)?,
|
||||
profile: self
|
||||
.profile
|
||||
.as_ref()
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
.cloned(),
|
||||
context: self
|
||||
.context
|
||||
.as_ref()
|
||||
.or(self.task.as_ref())
|
||||
.filter(|c| !c.is_empty())
|
||||
.cloned(),
|
||||
visibility: self.parse_visibility(action)?,
|
||||
}),
|
||||
"stop_agent" => Ok(OrchestratorCommand::StopAgent {
|
||||
name: self.require_name(action)?,
|
||||
}),
|
||||
"agent.stop" => Ok(OrchestratorCommand::StopAgent {
|
||||
name: self.require_target_agent(action)?,
|
||||
}),
|
||||
"update_agent_context" => Ok(OrchestratorCommand::UpdateAgentContext {
|
||||
name: self.require_name(action)?,
|
||||
context: self.require("context", action, self.context.as_deref())?,
|
||||
}),
|
||||
"create_skill" => Ok(OrchestratorCommand::CreateSkill {
|
||||
"agent.update_context" => Ok(OrchestratorCommand::UpdateAgentContext {
|
||||
name: self.require_target_agent(action)?,
|
||||
context: self.require("context", action, self.context.as_deref())?,
|
||||
}),
|
||||
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
|
||||
name: self.require_name(action)?,
|
||||
content: self.require("context", action, self.context.as_deref())?,
|
||||
scope: self.parse_scope(action)?,
|
||||
@ -165,11 +235,51 @@ impl OrchestratorRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `visibility` for `spawn_agent`, defaulting to background.
|
||||
fn parse_visibility(&self, action: &str) -> Result<OrchestratorVisibility, OrchestratorError> {
|
||||
match self.visibility.as_deref().map(str::trim) {
|
||||
None | Some("") | Some("background") => Ok(OrchestratorVisibility::Background),
|
||||
Some("visible") => {
|
||||
let node_id = self.node_id.or(self.attach_to_cell).ok_or_else(|| {
|
||||
OrchestratorError::MissingField {
|
||||
action: action.to_owned(),
|
||||
field: "nodeId".to_owned(),
|
||||
}
|
||||
})?;
|
||||
Ok(OrchestratorVisibility::Visible { node_id })
|
||||
}
|
||||
Some(other) => Err(OrchestratorError::UnknownVisibility {
|
||||
action: action.to_owned(),
|
||||
visibility: 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())
|
||||
}
|
||||
|
||||
fn require_target_agent(&self, action: &str) -> Result<String, OrchestratorError> {
|
||||
self.require(
|
||||
"targetAgent",
|
||||
action,
|
||||
self.target_agent.as_deref().or(self.name.as_deref()),
|
||||
)
|
||||
}
|
||||
|
||||
fn action_name(&self) -> Result<&str, OrchestratorError> {
|
||||
self.request_type
|
||||
.as_deref()
|
||||
.or(self.action.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|a| !a.is_empty())
|
||||
.ok_or_else(|| OrchestratorError::MissingField {
|
||||
action: "<request>".to_owned(),
|
||||
field: "type".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Requires `value` to be present and non-empty (after trimming), else a
|
||||
/// [`OrchestratorError::MissingField`] naming `field`/`action`.
|
||||
fn require(
|
||||
@ -205,8 +315,9 @@ mod tests {
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name: "dev-backend".to_owned(),
|
||||
profile: "claude-code".to_owned(),
|
||||
profile: Some("claude-code".to_owned()),
|
||||
context: Some("agents/dev-backend.md".to_owned()),
|
||||
visibility: OrchestratorVisibility::Background,
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -218,12 +329,43 @@ mod tests {
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name: "a".to_owned(),
|
||||
profile: "claude-code".to_owned(),
|
||||
profile: Some("claude-code".to_owned()),
|
||||
context: None,
|
||||
visibility: OrchestratorVisibility::Background,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_visible_requires_and_carries_node_id() {
|
||||
let node = uuid::Uuid::from_u128(42);
|
||||
let r = req(&format!(
|
||||
r#"{{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible", "nodeId": "{node}" }}"#
|
||||
));
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name: "a".to_owned(),
|
||||
profile: Some("claude-code".to_owned()),
|
||||
context: None,
|
||||
visibility: OrchestratorVisibility::Visible {
|
||||
node_id: NodeId::from_uuid(node)
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let missing = req(
|
||||
r#"{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible" }"#,
|
||||
);
|
||||
assert_eq!(
|
||||
missing.validate(),
|
||||
Err(OrchestratorError::MissingField {
|
||||
action: "spawn_agent".to_owned(),
|
||||
field: "nodeId".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_missing_profile_is_rejected() {
|
||||
let r = req(r#"{ "action": "spawn_agent", "name": "a" }"#);
|
||||
@ -236,6 +378,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_run_accepts_type_target_agent_and_optional_profile() {
|
||||
let r = req(
|
||||
r#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "Architect", "task": "Analyse", "visibility": "background" }"#,
|
||||
);
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name: "Architect".to_owned(),
|
||||
profile: None,
|
||||
context: Some("Analyse".to_owned()),
|
||||
visibility: OrchestratorVisibility::Background,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_run_visible_accepts_attach_to_cell() {
|
||||
let node = uuid::Uuid::from_u128(77);
|
||||
let r = req(&format!(
|
||||
r#"{{ "type": "agent.run", "targetAgent": "Architect", "profile": "claude-code", "visibility": "visible", "attachToCell": "{node}" }}"#
|
||||
));
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name: "Architect".to_owned(),
|
||||
profile: Some("claude-code".to_owned()),
|
||||
context: None,
|
||||
visibility: OrchestratorVisibility::Visible {
|
||||
node_id: NodeId::from_uuid(node)
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_agent_validates() {
|
||||
let r = req(r#"{ "action": "stop_agent", "name": "dev-backend" }"#);
|
||||
@ -261,9 +438,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn update_context_requires_a_body() {
|
||||
let ok = req(
|
||||
r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##,
|
||||
);
|
||||
let ok =
|
||||
req(r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##);
|
||||
assert_eq!(
|
||||
ok.validate().unwrap(),
|
||||
OrchestratorCommand::UpdateAgentContext {
|
||||
@ -284,9 +460,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn create_skill_defaults_to_project_scope() {
|
||||
let r = req(
|
||||
r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##,
|
||||
);
|
||||
let r =
|
||||
req(r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##);
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::CreateSkill {
|
||||
@ -343,7 +518,9 @@ mod tests {
|
||||
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);
|
||||
assert_eq!(
|
||||
r.validate(),
|
||||
Err(OrchestratorError::UnknownAction("delete_everything".to_owned()))
|
||||
Err(OrchestratorError::UnknownAction(
|
||||
"delete_everything".to_owned()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, SessionId};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::profile::AgentProfile;
|
||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||
use crate::project::{Project, ProjectPath};
|
||||
use crate::remote::RemoteKind;
|
||||
use crate::skill::{Skill, SkillScope};
|
||||
@ -798,6 +798,94 @@ pub trait ProfileStore: Send + Sync {
|
||||
async fn mark_configured(&self) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// CRUD for the declarative [`EmbedderProfile`]s in the global IDE store
|
||||
/// (`embedder.json`, LOT C, §14.5.3). Mirrors [`ProfileStore`] for the embedding
|
||||
/// engines: adding an engine is *data* the user configures, not code.
|
||||
///
|
||||
/// There is **no** `is_configured`/`mark_configured`: the default embedder posture
|
||||
/// is `none` **by absence of the file** (an empty/missing `embedder.json` ⇒ recall
|
||||
/// stays the dependency-free naïve étage 1). A configured embedder takes effect at
|
||||
/// the **next IDE start** (the composition root freezes the recall for the session).
|
||||
#[async_trait]
|
||||
pub trait EmbedderProfileStore: Send + Sync {
|
||||
/// Lists all configured embedder profiles (empty when none configured).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError>;
|
||||
|
||||
/// Saves (creates or replaces by id) an embedder profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError>;
|
||||
|
||||
/// Deletes an embedder profile by id.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError::NotFound`] if no profile carries that id.
|
||||
async fn delete(&self, id: &str) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Best-effort snapshot of the embedding *environment*: which local engines are
|
||||
/// already installed/cached on this machine. Drives the "configure an embedder?"
|
||||
/// UI (C2/C3) so it can detect an existing engine before suggesting a download.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct EmbedderEnvReport {
|
||||
/// Whether an Ollama-style local embedding server was detected (best-effort;
|
||||
/// always `false` in a build without the HTTP capability).
|
||||
pub ollama_detected: bool,
|
||||
/// Ids of the recommended ONNX models already present in the local cache.
|
||||
pub onnx_cached_models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Probes the local embedding environment (installed/cached engines). **Best-effort
|
||||
/// by contract**: [`inspect`](Self::inspect) never errors — an unreachable host or
|
||||
/// an unreadable cache simply yields a report with the corresponding fields unset.
|
||||
#[async_trait]
|
||||
pub trait EmbedderEnvInspector: Send + Sync {
|
||||
/// Returns a best-effort snapshot of the local embedding environment.
|
||||
async fn inspect(&self) -> EmbedderEnvReport;
|
||||
}
|
||||
|
||||
/// The persisted user response to the "configure an embedder?" suggestion
|
||||
/// (ARCHITECTURE §14.5.5, LOT C3), stored per project under
|
||||
/// `.ideai/memory/.embedder-prompt.json`. Absence of the file ⇒ never answered.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EmbedderPromptDismissal {
|
||||
/// "Plus tard" — re-proposable on a future session (not this one).
|
||||
Later,
|
||||
/// "Ne plus demander" — never publish the suggestion again (persistent).
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Persistence of the per-project embedder-suggestion state
|
||||
/// (`.ideai/memory/.embedder-prompt.json`, LOT C3). A fine-grained port (Interface
|
||||
/// Segregation) so the suggestion use cases depend on this alone, not on a wider
|
||||
/// store. **Best-effort reads**: a missing/malformed file is *not* an error — it
|
||||
/// reads as "never answered" ([`None`]).
|
||||
#[async_trait]
|
||||
pub trait EmbedderPromptStore: Send + Sync {
|
||||
/// Reads the recorded dismissal for `root`'s project, or `None` when the user
|
||||
/// has never answered (no file / unreadable / malformed).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] only on an unexpected I/O failure the adapter chooses to
|
||||
/// surface; a plain missing file degrades to `Ok(None)`.
|
||||
async fn read(&self, root: &ProjectPath)
|
||||
-> Result<Option<EmbedderPromptDismissal>, StoreError>;
|
||||
|
||||
/// Records the user's dismissal choice for `root`'s project.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on a persistence failure.
|
||||
async fn write(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
dismissal: EmbedderPromptDismissal,
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Reads/writes agent `.md` contexts and the project manifest, within a project.
|
||||
#[async_trait]
|
||||
pub trait AgentContextStore: Send + Sync {
|
||||
@ -895,8 +983,7 @@ pub trait GitPort: Send + Sync {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn log(&self, root: &ProjectPath, limit: usize)
|
||||
-> Result<Vec<GitCommitInfo>, GitError>;
|
||||
async fn log(&self, root: &ProjectPath, limit: usize) -> Result<Vec<GitCommitInfo>, GitError>;
|
||||
|
||||
/// Returns the commit graph (all local branches, topological + time sort).
|
||||
///
|
||||
|
||||
@ -105,10 +105,19 @@ fn profile_uses_camel_case_keys_and_skips_none_options() {
|
||||
)
|
||||
.unwrap();
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(json.contains("\"apiKeyEnv\":\"MY_KEY\""), "camelCase apiKeyEnv: {json}");
|
||||
assert!(json.contains("\"strategy\":\"api\""), "camelCase strategy: {json}");
|
||||
assert!(
|
||||
json.contains("\"apiKeyEnv\":\"MY_KEY\""),
|
||||
"camelCase apiKeyEnv: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"strategy\":\"api\""),
|
||||
"camelCase strategy: {json}"
|
||||
);
|
||||
// `model` is None ⇒ skipped from the wire form.
|
||||
assert!(!json.contains("\"model\""), "None model must be skipped: {json}");
|
||||
assert!(
|
||||
!json.contains("\"model\""),
|
||||
"None model must be skipped: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -138,7 +147,10 @@ fn none_profile_has_none_strategy() {
|
||||
let p = EmbedderProfile::none();
|
||||
assert_eq!(p.strategy, EmbedderStrategy::None);
|
||||
assert_eq!(p.id, "none");
|
||||
assert!(p.dimension > 0, "even the none profile keeps a non-zero dimension");
|
||||
assert!(
|
||||
p.dimension > 0,
|
||||
"even the none profile keeps a non-zero dimension"
|
||||
);
|
||||
// And it round-trips like any other profile.
|
||||
roundtrip(&p);
|
||||
}
|
||||
@ -151,7 +163,9 @@ fn none_profile_has_none_strategy() {
|
||||
fn new_rejects_empty_id() {
|
||||
assert!(matches!(
|
||||
EmbedderProfile::new("", "Name", EmbedderStrategy::None, None, None, None, 8),
|
||||
Err(DomainError::EmptyField { field: "embedder.id" })
|
||||
Err(DomainError::EmptyField {
|
||||
field: "embedder.id"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
@ -177,7 +191,8 @@ fn new_rejects_zero_dimension() {
|
||||
|
||||
#[test]
|
||||
fn new_accepts_valid_minimal_profile() {
|
||||
let p = EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 1).unwrap();
|
||||
let p =
|
||||
EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 1).unwrap();
|
||||
assert_eq!(p.dimension, 1);
|
||||
assert_eq!(p.strategy, EmbedderStrategy::None);
|
||||
}
|
||||
|
||||
@ -5,8 +5,8 @@ mod helpers;
|
||||
|
||||
use domain::{
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
|
||||
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SessionStrategy,
|
||||
Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
|
||||
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef,
|
||||
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
|
||||
};
|
||||
use helpers::{AtomicSeqIdGenerator, FixedClock};
|
||||
use uuid::Uuid;
|
||||
@ -203,8 +203,17 @@ fn profile_rejects_empty_command() {
|
||||
|
||||
#[test]
|
||||
fn profile_rejects_empty_name() {
|
||||
let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}", None)
|
||||
.unwrap_err();
|
||||
let err = AgentProfile::new(
|
||||
profile_id(),
|
||||
"",
|
||||
"claude",
|
||||
vec![],
|
||||
ci_stdin(),
|
||||
None,
|
||||
"{r}",
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name"));
|
||||
}
|
||||
|
||||
@ -331,7 +340,8 @@ fn template_version_next_increments() {
|
||||
|
||||
#[test]
|
||||
fn template_rejects_empty_name() {
|
||||
let err = AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err();
|
||||
let err =
|
||||
AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
@ -345,9 +355,16 @@ fn agent_id(n: u128) -> domain::AgentId {
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_synchronized_requires_template_metadata() {
|
||||
let err =
|
||||
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, true, None)
|
||||
.unwrap_err();
|
||||
let err = ManifestEntry::new(
|
||||
agent_id(1),
|
||||
"A",
|
||||
"agents/a.md",
|
||||
profile_id(),
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||
|
||||
// template id present but version missing → still rejected.
|
||||
@ -366,9 +383,16 @@ fn manifest_entry_synchronized_requires_template_metadata() {
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_rejects_empty_name() {
|
||||
let err =
|
||||
ManifestEntry::new(agent_id(1), " ", "agents/a.md", profile_id(), None, false, None)
|
||||
.unwrap_err();
|
||||
let err = ManifestEntry::new(
|
||||
agent_id(1),
|
||||
" ",
|
||||
"agents/a.md",
|
||||
profile_id(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
@ -416,24 +440,52 @@ fn manifest_entry_agent_roundtrip() {
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_duplicate_md_path() {
|
||||
let e1 =
|
||||
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None)
|
||||
.unwrap();
|
||||
let e2 =
|
||||
ManifestEntry::new(agent_id(2), "B", "agents/a.md", profile_id(), None, false, None)
|
||||
.unwrap();
|
||||
let e1 = ManifestEntry::new(
|
||||
agent_id(1),
|
||||
"A",
|
||||
"agents/a.md",
|
||||
profile_id(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let e2 = ManifestEntry::new(
|
||||
agent_id(2),
|
||||
"B",
|
||||
"agents/a.md",
|
||||
profile_id(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err();
|
||||
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_unique_md_paths_ok() {
|
||||
let e1 =
|
||||
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None)
|
||||
.unwrap();
|
||||
let e2 =
|
||||
ManifestEntry::new(agent_id(2), "B", "agents/b.md", profile_id(), None, false, None)
|
||||
.unwrap();
|
||||
let e1 = ManifestEntry::new(
|
||||
agent_id(1),
|
||||
"A",
|
||||
"agents/a.md",
|
||||
profile_id(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let e2 = ManifestEntry::new(
|
||||
agent_id(2),
|
||||
"B",
|
||||
"agents/b.md",
|
||||
profile_id(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
|
||||
}
|
||||
|
||||
@ -465,7 +517,12 @@ fn skill_rejects_empty_name() {
|
||||
SkillScope::Project,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, DomainError::EmptyField { field: "skill.name" });
|
||||
assert_eq!(
|
||||
err,
|
||||
DomainError::EmptyField {
|
||||
field: "skill.name"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -72,9 +72,7 @@ impl Default for AtomicSeqIdGenerator {
|
||||
|
||||
impl IdGenerator for AtomicSeqIdGenerator {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
let n = self
|
||||
.next
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let n = self.next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Uuid::from_u128(u128::from(n))
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,11 @@
|
||||
|
||||
mod helpers;
|
||||
|
||||
use domain::ids::AgentId;
|
||||
use domain::{
|
||||
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
|
||||
SplitContainer, WeightedChild,
|
||||
};
|
||||
use domain::ids::AgentId;
|
||||
use helpers::{node, session};
|
||||
|
||||
fn agent_id(n: u128) -> AgentId {
|
||||
@ -463,6 +463,36 @@ fn set_session_duplicate_across_leaves_rejected() {
|
||||
assert_eq!(err, LayoutError::DuplicateSession(session(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_session_attaches_background_session_to_leaf() {
|
||||
let tree = two_leaves(None, None);
|
||||
let out = tree.attach_session(node(2), session(100)).unwrap();
|
||||
assert_eq!(session_for(&out, node(1)), None);
|
||||
assert_eq!(session_for(&out, node(2)), Some(session(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_session_moves_visible_session_to_target_leaf() {
|
||||
let tree = two_leaves(Some(100), None);
|
||||
let out = tree.attach_session(node(2), session(100)).unwrap();
|
||||
assert_eq!(session_for(&out, node(1)), None);
|
||||
assert_eq!(session_for(&out, node(2)), Some(session(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_session_same_leaf_is_idempotent() {
|
||||
let tree = two_leaves(Some(100), None);
|
||||
let out = tree.attach_session(node(1), session(100)).unwrap();
|
||||
assert_eq!(out, tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_session_rejects_target_occupied_by_other_session() {
|
||||
let tree = two_leaves(Some(100), Some(200));
|
||||
let err = tree.attach_session(node(2), session(100)).unwrap_err();
|
||||
assert_eq!(err, LayoutError::CrossContainer);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_cell_agent (#3: per-cell agent)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -503,7 +533,9 @@ fn set_cell_agent_is_immutable_source_unchanged() {
|
||||
#[test]
|
||||
fn set_cell_agent_missing_leaf_is_node_not_found() {
|
||||
let tree = single(1, None);
|
||||
let err = tree.set_cell_agent(node(404), Some(agent_id(1))).unwrap_err();
|
||||
let err = tree
|
||||
.set_cell_agent(node(404), Some(agent_id(1)))
|
||||
.unwrap_err();
|
||||
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,7 @@
|
||||
//! [`MemorySlug`] validation, `[[slug]]` link scanning, frontmatter serde
|
||||
//! round-trip, and the derived index entry.
|
||||
|
||||
use domain::{
|
||||
DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType,
|
||||
};
|
||||
use domain::{DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
|
||||
fn fm(slug: &str, description: &str, ty: MemoryType) -> MemoryFrontmatter {
|
||||
MemoryFrontmatter {
|
||||
@ -93,7 +91,10 @@ fn memory_rejects_empty_description() {
|
||||
|
||||
#[test]
|
||||
fn memory_rejects_empty_body() {
|
||||
let r = Memory::new(fm("ok-slug", "hook", MemoryType::User), MarkdownDoc::new(""));
|
||||
let r = Memory::new(
|
||||
fm("ok-slug", "hook", MemoryType::User),
|
||||
MarkdownDoc::new(""),
|
||||
);
|
||||
assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
@ -103,7 +104,9 @@ fn memory_rejects_empty_body() {
|
||||
|
||||
#[test]
|
||||
fn outgoing_links_none() {
|
||||
assert!(note("n", "plain body, no links").outgoing_links().is_empty());
|
||||
assert!(note("n", "plain body, no links")
|
||||
.outgoing_links()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -6,8 +6,8 @@ mod helpers;
|
||||
use domain::{
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
|
||||
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
|
||||
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion,
|
||||
WeightedChild,
|
||||
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth,
|
||||
TemplateVersion, WeightedChild,
|
||||
};
|
||||
use helpers::{node, session};
|
||||
use uuid::Uuid;
|
||||
@ -72,7 +72,14 @@ fn project_uses_camel_case_and_tagged_remote() {
|
||||
|
||||
#[test]
|
||||
fn remote_ssh_roundtrip_and_tags() {
|
||||
let r = RemoteRef::ssh("host", 2222, "me", SshAuth::Key { path: "/k".into() }, "/srv").unwrap();
|
||||
let r = RemoteRef::ssh(
|
||||
"host",
|
||||
2222,
|
||||
"me",
|
||||
SshAuth::Key { path: "/k".into() },
|
||||
"/srv",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(roundtrip(&r), r);
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
assert!(json.contains("\"kind\":\"ssh\""), "json was {json}");
|
||||
@ -118,9 +125,12 @@ fn profile_roundtrip_all_injection_variants() {
|
||||
|
||||
#[test]
|
||||
fn context_injection_strategy_tag_is_camel_case() {
|
||||
let json = serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.unwrap();
|
||||
assert!(json.contains("\"strategy\":\"conventionFile\""), "json was {json}");
|
||||
let json =
|
||||
serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap()).unwrap();
|
||||
assert!(
|
||||
json.contains("\"strategy\":\"conventionFile\""),
|
||||
"json was {json}"
|
||||
);
|
||||
let json = serde_json::to_string(&ContextInjection::stdin()).unwrap();
|
||||
assert!(json.contains("\"strategy\":\"stdin\""), "json was {json}");
|
||||
}
|
||||
@ -203,7 +213,10 @@ fn session_assign_flag_omitted_when_none() {
|
||||
let session = SessionStrategy::new(None, "--continue").unwrap();
|
||||
let json = serde_json::to_string(&session).unwrap();
|
||||
assert!(!json.contains("assignFlag"), "json was {json}");
|
||||
assert!(json.contains("\"resumeFlag\":\"--continue\""), "json was {json}");
|
||||
assert!(
|
||||
json.contains("\"resumeFlag\":\"--continue\""),
|
||||
"json was {json}"
|
||||
);
|
||||
assert_eq!(roundtrip(&session), session);
|
||||
}
|
||||
|
||||
@ -244,13 +257,22 @@ fn agent_roundtrip_from_template() {
|
||||
let json = serde_json::to_string(&a).unwrap();
|
||||
assert!(json.contains("\"contextPath\""), "json was {json}");
|
||||
// AgentOrigin tagged with `type`, camelCased.
|
||||
assert!(json.contains("\"type\":\"fromTemplate\""), "json was {json}");
|
||||
assert!(
|
||||
json.contains("\"type\":\"fromTemplate\""),
|
||||
"json was {json}"
|
||||
);
|
||||
// Inner fields must be camelCased per ARCHITECTURE §9.1:
|
||||
// { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }.
|
||||
assert!(json.contains("\"templateId\""), "json was {json}");
|
||||
assert!(json.contains("\"syncedTemplateVersion\":4"), "json was {json}");
|
||||
assert!(
|
||||
json.contains("\"syncedTemplateVersion\":4"),
|
||||
"json was {json}"
|
||||
);
|
||||
assert!(!json.contains("\"template_id\""), "json was {json}");
|
||||
assert!(!json.contains("\"synced_template_version\""), "json was {json}");
|
||||
assert!(
|
||||
!json.contains("\"synced_template_version\""),
|
||||
"json was {json}"
|
||||
);
|
||||
assert!(!json.contains("\"synced_version\""), "json was {json}");
|
||||
}
|
||||
|
||||
@ -285,14 +307,18 @@ fn manifest_roundtrip_and_camel_case() {
|
||||
Some(TemplateVersion(5)),
|
||||
)
|
||||
.unwrap();
|
||||
let e2 = ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap();
|
||||
let e2 =
|
||||
ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap();
|
||||
let m = AgentManifest::new(1, vec![e1, e2]).unwrap();
|
||||
assert_eq!(roundtrip(&m), m);
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
// entries are serialized under "agents".
|
||||
assert!(json.contains("\"agents\":["), "json was {json}");
|
||||
assert!(json.contains("\"mdPath\""), "json was {json}");
|
||||
assert!(json.contains("\"syncedTemplateVersion\":5"), "json was {json}");
|
||||
assert!(
|
||||
json.contains("\"syncedTemplateVersion\":5"),
|
||||
"json was {json}"
|
||||
);
|
||||
// Non-synchronized entry omits optional template fields (skip_serializing_if).
|
||||
assert!(!json.contains("\"templateId\":null"), "json was {json}");
|
||||
}
|
||||
@ -307,13 +333,25 @@ fn sid(n: u128) -> SkillId {
|
||||
|
||||
#[test]
|
||||
fn skill_roundtrip_and_camel_case_scope() {
|
||||
let s = Skill::new(sid(1), "code-review", MarkdownDoc::new("body"), SkillScope::Global).unwrap();
|
||||
let s = Skill::new(
|
||||
sid(1),
|
||||
"code-review",
|
||||
MarkdownDoc::new("body"),
|
||||
SkillScope::Global,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(roundtrip(&s), s);
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert!(json.contains("\"scope\":\"global\""), "json was {json}");
|
||||
assert!(json.contains("\"contentMd\""), "json was {json}");
|
||||
|
||||
let p = Skill::new(sid(2), "simplify", MarkdownDoc::new("b"), SkillScope::Project).unwrap();
|
||||
let p = Skill::new(
|
||||
sid(2),
|
||||
"simplify",
|
||||
MarkdownDoc::new("b"),
|
||||
SkillScope::Project,
|
||||
)
|
||||
.unwrap();
|
||||
let pj = serde_json::to_string(&p).unwrap();
|
||||
assert!(pj.contains("\"scope\":\"project\""), "json was {pj}");
|
||||
}
|
||||
@ -412,7 +450,10 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
|
||||
}
|
||||
let json = serde_json::to_string(&tree).unwrap();
|
||||
// agent present when set
|
||||
assert!(json.contains("\"agent\""), "agent field should be present when set; json was {json}");
|
||||
assert!(
|
||||
json.contains("\"agent\""),
|
||||
"agent field should be present when set; json was {json}"
|
||||
);
|
||||
// null variant omitted
|
||||
let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell {
|
||||
id: node(2),
|
||||
@ -422,5 +463,8 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
|
||||
agent_was_running: false,
|
||||
}));
|
||||
let json2 = serde_json::to_string(&tree_no_agent).unwrap();
|
||||
assert!(!json2.contains("\"agent\""), "agent field should be omitted when None; json was {json2}");
|
||||
assert!(
|
||||
!json2.contains("\"agent\""),
|
||||
"agent field should be omitted when None; json was {json2}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
//! window is dropped; an active moved tab hands activity back to a sibling.
|
||||
|
||||
use domain::{
|
||||
LayoutNode, LayoutTree, LayoutError, LeafCell, NodeId, ProjectId, Tab, TabId, Window,
|
||||
WindowId, Workspace,
|
||||
LayoutError, LayoutNode, LayoutTree, LeafCell, NodeId, ProjectId, Tab, TabId, Window, WindowId,
|
||||
Workspace,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user