Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/agent/src/local_agent/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6858,6 +6858,53 @@ mod tests {
stored.result["properties"]
);
}

/// A collection's description is a declared field, so the agent sets
/// it on create and changes it on update like any other field value,
/// and it lands in the collection bucket.
#[tokio::test(flavor = "multi_thread")]
async fn create_and_update_set_a_collection_description() {
let (ns, _tmp) = make_test_service().await;
let executor = plain_executor(ns.clone());

let created = executor
.execute(
"create_node",
json!({
"content": "Clients",
"node_type": "collection",
"field_values": { "description": "Accounts we bill" },
}),
)
.await
.unwrap();
assert!(!created.is_error, "{created:?}");
let node_id = strip_node_uri(created.result["id"].as_str().expect("id")).to_string();

let stored = ns.get_node(&node_id).await.unwrap().unwrap();
assert_eq!(
stored.properties,
json!({ "collection": { "description": "Accounts we bill" } })
);

let updated = executor
.execute(
"update_node",
json!({
"id": node_id,
"field_values": { "description": "Accounts we bill, one page each" },
}),
)
.await
.unwrap();
assert!(!updated.is_error, "{updated:?}");

let stored = ns.get_node(&node_id).await.unwrap().unwrap();
assert_eq!(
stored.properties,
json!({ "collection": { "description": "Accounts we bill, one page each" } })
);
}
}

mod update_node_noop_gate {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ Call exactly one tool. Do not answer the user in prose when a tool applies.
Current date: 2026-06-15
Active model: gemma-4-E4B-it-Q4_K_M

COLLECTIONS: Engineering, Q3 Planning
COLLECTIONS:
- Engineering — Design notes, ADRs and tickets for the product team
- Q3 Planning

MENTIONED ENTITIES (already resolved — use these ids directly, do not ask for one):
- "Northwind Trading" (company_sold_to) id=01J8ZQ3K7X2N4P6R8T0V2W4Y6A
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
COLLECTIONS: Engineering, Q3 Planning
COLLECTIONS:
- Engineering — Design notes, ADRs and tickets for the product team
- Q3 Planning

MENTIONED ENTITIES (already resolved — use these ids directly, do not ask for one):
- "Northwind Trading" (company_sold_to) id=01J8ZQ3K7X2N4P6R8T0V2W4Y6A
Expand Down
13 changes: 11 additions & 2 deletions packages/agent/tests/it/prompt_assembly_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ use nodespace_core::db::ResolvedEntity;
use nodespace_core::markdown::{prepare_nodes_from_template, NodeTemplate, PreparedNode};
use nodespace_core::models::schema::EnumValue;
use nodespace_core::models::{Node, SchemaField, SchemaProtectionLevel, SkillNode};
use nodespace_core::ops::context_ops::{EntityResolution, PlaybookInfo, WorkspaceContext};
use nodespace_core::ops::context_ops::{
CollectionSummary, EntityResolution, PlaybookInfo, WorkspaceContext,
};
use nodespace_core::ops::entity_types_block::EntityTypeDescriptor;
use nodespace_core::services::render_subtree_markdown;

Expand Down Expand Up @@ -287,7 +289,14 @@ fn fixture_schema_release() -> nodespace_core::models::SchemaNode {

fn fixture_workspace_context() -> WorkspaceContext {
WorkspaceContext {
collections: vec!["Engineering".to_string(), "Q3 Planning".to_string()],
collections: vec![
CollectionSummary {
name: "Engineering".to_string(),
description: "Design notes, ADRs and tickets for the product team".to_string(),
},
// Exercises the no-description rendering branch.
CollectionSummary::named("Q3 Planning"),
],
active_playbooks: vec![
PlaybookInfo {
name: "Sprint Close-Out".to_string(),
Expand Down
87 changes: 87 additions & 0 deletions packages/cli/tests/it/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3759,3 +3759,90 @@ async fn routing_with_no_selection_pins_the_resolved_default_database_id() {

let _ = shutdown.send(());
}

/// `node create --type collection` makes the collection the name identifies:
/// the same id an import or the app's create derives, with the description in
/// the collection bucket. `node update --property description=…` changes it.
#[tokio::test]
async fn node_create_and_update_set_a_collection_description() {
use nodespace_core::services::collection_service::deterministic_collection_id;

let (sock, shutdown, _tempdir) = spawn_test_daemon().await;
let mut client = connect(&sock, DatabaseIdInterceptor::none())
.await
.expect("connect");
let mut raw = connect(&sock, DatabaseIdInterceptor::none())
.await
.expect("raw connect");

let create = |name: &str| {
commands::node::NodeAction::Create(commands::node::CreateArgs {
node_type: "collection".into(),
content: Some(name.into()),
parent: None,
properties: vec![("description".into(), serde_json::json!("Accounts we bill"))],
collections: vec![],
collection_ids: vec![],
})
};
commands::node::run(&mut client, create("Clients"), true)
.await
.expect("create collection");

let id = deterministic_collection_id("Clients");
let stored_description = |raw: &mut NodeClient| {
let mut raw = raw.clone();
let id = id.clone();
async move {
let node = raw
.get_node(GetNodeRequest { node_id: id })
.await
.expect("the collection exists at its deterministic id")
.into_inner()
.node_data
.expect("node_data");
let props: serde_json::Value =
serde_json::from_str(&node.properties).expect("parse properties");
assert!(
props.get("description").is_none(),
"no flat description key: {props}"
);
props["collection"]["description"].clone()
}
};
assert_eq!(stored_description(&mut raw).await, "Accounts we bill");

// A second create of the same name is the same collection, so it is
// refused rather than stored as a duplicate under another id.
let err = commands::node::run(&mut client, create("clients"), true)
.await
.expect_err("a duplicate collection name is refused");
assert!(
format!("{err:#}").contains("Already exists"),
"expected an already-exists error, got: {err:#}"
);

commands::node::run(
&mut client,
commands::node::NodeAction::Update(commands::node::UpdateArgs {
id: id.clone(),
content: None,
properties: vec![(
"description".into(),
serde_json::json!("Accounts we bill, one page each"),
)],
collections: vec![],
collection_ids: vec![],
remove_collection_ids: vec![],
}),
true,
)
.await
.expect("update description");
assert_eq!(
stored_description(&mut raw).await,
"Accounts we bill, one page each"
);

let _ = shutdown.send(());
}
21 changes: 15 additions & 6 deletions packages/core/src/db/sqlite_store/relationships.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1142,13 +1142,22 @@ impl SqliteStore {
Ok(member_ids)
}

pub async fn get_all_collection_names(&self) -> Result<Vec<String>> {
/// Every collection's name and description, sorted by name. The
/// description is `None` when the collection has none.
///
/// Only a text value is read as a description. Nothing type-checks a text
/// field on write, and reading a stored number or boolean as a `String`
/// panics in the driver rather than returning an error.
pub async fn get_all_collection_descriptions(&self) -> Result<Vec<(String, Option<String>)>> {
let mut rows = self
.read()
.await?
.query(
&format!(
"SELECT content FROM node WHERE {} ORDER BY content ASC",
"SELECT content, \
CASE WHEN json_type(properties, '$.collection.description') = 'text' \
THEN json_extract(properties, '$.collection.description') END \
FROM node WHERE {} ORDER BY content ASC",
crate::db::schema::is_a_sql(
"node_type",
&[crate::models::CoreNodeType::Collection]
Expand All @@ -1157,13 +1166,13 @@ impl SqliteStore {
(),
)
.await
.context("Failed to get all collection names")?;
.context("Failed to get all collection descriptions")?;

let mut names = Vec::new();
let mut collections = Vec::new();
while let Some(row) = rows.next().await? {
names.push(row.get(0)?);
collections.push((row.get(0)?, row.get(1)?));
}
Ok(names)
Ok(collections)
}

pub async fn get_all_collections_with_member_counts(
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/models/core_schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2220,6 +2220,22 @@ mod tests {
}
}

#[test]
fn test_collection_declares_only_an_optional_description() {
// A collection is grouping only (ADR-083 §5): its one field says what
// the collection is for, and it carries no access-control fields.
let schemas = get_core_schemas();
let collection = schemas.iter().find(|s| s.id == "collection").unwrap();

let names: Vec<&str> = collection.fields.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["description"]);

let description = collection.get_field("description").unwrap();
assert_eq!(description.field_type, crate::models::SchemaFieldType::Text);
assert_eq!(description.required, Some(false));
assert_eq!(description.default, None);
}

#[test]
fn test_person_email_field_is_optional() {
// The description is seeded into every new database and shown to users
Expand Down
Loading