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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions packages/agent/src/agent_catalog/context_assembly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ const SKILL_CLI_REFERENCE: &str = include_str!("../../../skill/references/cli.md
const SKILL_SHARED_WORKSPACES: &str =
include_str!("../../../skill/references/shared-workspaces.md");

/// `packages/skill/references/graph-authored-guidance.md`, embedded for the
/// same reason as [`SKILL_MD`].
///
/// SKILL.md links to this by relative path before its first `skill guidance`
/// call — it covers the trust boundary for graph-fetched procedural
/// guidance. Embedding it too means a PTY session gets the whole skill
/// rather than a body pointing at a file that was never written next to it.
const SKILL_GRAPH_AUTHORED_GUIDANCE: &str =
include_str!("../../../skill/references/graph-authored-guidance.md");

/// Default token budget when none is specified by the caller.
const DEFAULT_TOKEN_BUDGET: u32 = 50_000;

Expand Down Expand Up @@ -389,6 +399,11 @@ async fn write_skill_md(session_dir: &Path) -> Result<(), ContextError> {
SKILL_SHARED_WORKSPACES,
)
.await?;
tokio::fs::write(
references.join("graph-authored-guidance.md"),
SKILL_GRAPH_AUTHORED_GUIDANCE,
)
.await?;
Ok(())
}

Expand Down Expand Up @@ -751,6 +766,11 @@ mod tests {
assert!(SKILL_SHARED_WORKSPACES.contains("shared"));
}

#[test]
fn embedded_graph_authored_guidance_matches_source_file() {
assert!(SKILL_GRAPH_AUTHORED_GUIDANCE.contains("graph-fetched"));
}

#[tokio::test]
async fn write_skill_md_writes_embedded_content() {
let session_dir = tempfile::tempdir().unwrap();
Expand Down
191 changes: 191 additions & 0 deletions packages/agent/tests/skill_guidance_fetch_mechanism.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//! Data-layer proof of the graph's fetch-at-activation guidance mechanism,
//! run against the real production seed registry
//! (`skill_pipeline::seed_skill_nodes`) rather than a synthetic node type,
//! and without an embedding model -- headless, no daemon, no gRPC -- so it
//! runs in every normal `cargo test`.
//!
//! `nodespace skill guidance` (packages/cli) is a thin formatting layer over
//! exactly the reads this test performs directly on `NodeService`
//! (`query_nodes_by_type("skill")` + `get_children`, the same structural
//! walk `search_ops::search_semantic`'s `include_markdown` path uses to
//! build a result's aggregated subtree markdown); that CLI layer additionally
//! requires a live embedding model for its semantic-search RPC, which this
//! environment doesn't have, so the two mechanisms it depends on are proven
//! here instead, at the layer the CLI itself reads from.
//!
//! 1. [`a_users_graph_edit_to_seeded_guidance_is_immediately_visible_to_a_fetch`] --
//! the runtime half of the mechanism: a user/team edit to a seeded
//! skill's guidance content (the same node an external agent's fetch
//! reads) reaches a fetch with no reseed or daemon restart involved.
//! 2. [`a_skill_rules_content_change_reaches_an_already_seeded_database`] --
//! the seeding prerequisite this mechanism depends on: proves, using the
//! real `seed_skill_nodes()` registry, that a compiled-template content
//! change (what editing `skill_pipeline.rs` produces) reaches an
//! existing install on next seed rather than being frozen by the first
//! node of that type ever created. The reconciliation this exercises
//! (`NodeService::seed_nodes_from_templates`) is per-node and
//! version-hash-keyed, replacing an older type-level skip that froze
//! every node of a type once one existed -- already the case well before
//! this test was written, so this is new coverage confirming the fix
//! still holds for the actual skill registry, not a re-implementation of
//! it.
//!
//! Both tests read the whole subtree via [`NodeService::get_subtree_data`] --
//! the same structural walk `search_ops::search_semantic`'s `include_markdown`
//! path recurses (a seeded skill's markdown parses into a nested hierarchy,
//! e.g. an H1 heading node with the guidance paragraph nested under it, not
//! a flat list of root children) -- rather than `get_children`, so these
//! assertions don't depend on assuming a particular nesting depth.

use std::sync::Arc;

use nodespace_agent::skill_pipeline::seed_skill_nodes;
use nodespace_core::db::SqliteStore;
use nodespace_core::markdown::prepare_nodes_from_template;
use nodespace_core::models::NodeUpdate;
use nodespace_core::services::NodeService;
use tempfile::TempDir;

const RESEARCH_AND_SEARCH: &str = "Research & Search";

async fn seeded_service() -> (NodeService, TempDir) {
let temp_dir = TempDir::new().expect("tempdir");
let db_path = temp_dir.path().join("test.db");
let mut store = Arc::new(SqliteStore::new(db_path).await.expect("store must open"));
let node_service = NodeService::new(&mut store)
.await
.expect("node service must init");

let groups: Vec<_> = seed_skill_nodes()
.iter()
.map(|t| prepare_nodes_from_template(t).expect("template must parse"))
.collect();
node_service
.seed_nodes_from_templates(groups)
.await
.expect("initial seed must succeed");

(node_service, temp_dir)
}

#[tokio::test]
async fn a_users_graph_edit_to_seeded_guidance_is_immediately_visible_to_a_fetch() {
let (node_service, _temp) = seeded_service().await;

let skills = node_service
.query_nodes_by_type("skill", None)
.await
.expect("query skills");
let root = skills
.iter()
.find(|n| n.content == RESEARCH_AND_SEARCH)
.expect("Research & Search must be seeded");

let (_root_node, node_map, _adjacency) = node_service
.get_subtree_data(&root.id)
.await
.expect("get subtree");
let target = node_map
.values()
.find(|n| n.id != root.id)
.expect("seeded guidance must expand into at least one descendant node")
.clone();
let original_content = target.content.clone();

// Simulate a user/team editing this guidance node directly in the graph
// -- the live-edit scenario the runtime half of this mechanism exists
// for, done through the same `update_node` path the desktop app and CLI
// both use.
let edited_content = "EDITED BY TEAM: prefer collection-scoped search before a broad query.";
node_service
.update_node(
&target.id,
target.version,
NodeUpdate::new().with_content(edited_content.to_string()),
)
.await
.expect("user edit must succeed");

// Fetch again -- the same structural read `nodespace skill guidance`
// performs under the hood (a full subtree walk), no reseed, no restart.
let (_root_after, node_map_after, _adjacency_after) = node_service
.get_subtree_data(&root.id)
.await
.expect("get subtree after edit");
let fetched = node_map_after
.get(&target.id)
.expect("edited node must still exist");

assert_eq!(
fetched.content, edited_content,
"a fetch immediately after a graph edit must return the user's own content"
);
assert_ne!(
fetched.content, original_content,
"sanity: the edit must actually have changed the content the fetch previously saw"
);
}

#[tokio::test]
async fn a_skill_rules_content_change_reaches_an_already_seeded_database() {
let (node_service, _temp) = seeded_service().await;

let skills_before = node_service
.query_nodes_by_type("skill", None)
.await
.expect("query skills");
assert!(
skills_before
.iter()
.any(|n| n.content == RESEARCH_AND_SEARCH),
"Research & Search must be present after the first seed"
);
let skill_count_before = skills_before.len();

// Simulate a `skill_pipeline.rs` content edit: the same seed key
// ("Research & Search", i.e. the same title), different compiled
// markdown -- what shipping a guidance fix produces.
let mut edited_templates = seed_skill_nodes();
let target = edited_templates
.iter_mut()
.find(|t| t.title == RESEARCH_AND_SEARCH)
.expect("Research & Search template must exist");
target.markdown_content =
"# Research & Search Guidance\n\nCHANGED: this is a post-ship content fix.".to_string();

let groups: Vec<_> = edited_templates
.iter()
.map(|t| prepare_nodes_from_template(t).expect("template must parse"))
.collect();
node_service
.seed_nodes_from_templates(groups)
.await
.expect("reseed against an already-seeded database must succeed");

let skills_after = node_service
.query_nodes_by_type("skill", None)
.await
.expect("query skills after reseed");
assert_eq!(
skills_after.len(),
skill_count_before,
"reconciliation must replace in place, not duplicate the skill node"
);

let root_after = skills_after
.iter()
.find(|n| n.content == RESEARCH_AND_SEARCH)
.expect("Research & Search must still be present after reseed");
let (_root_node, node_map_after, _adjacency) = node_service
.get_subtree_data(&root_after.id)
.await
.expect("get subtree after reseed");
assert!(
node_map_after.values().any(|n| n
.content
.contains("CHANGED: this is a post-ship content fix.")),
"a skill_pipeline.rs-style content change must reach the already-seeded database on \
next seed, not be frozen by the first skill node ever created (per-node \
version-hash-keyed reconciliation, not a type-level skip)"
);
}
5 changes: 5 additions & 0 deletions packages/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ nodespace-types = { workspace = true }
# POSIX-shell-style argument splitting, applied to the `mcp` passthrough
# tool's `args` string.
shell-words = "1.1"
# Per-invocation random tag for `skill guidance`'s provenance banner (see
# `commands::skill::provenance_tag`) — the same OS-backed randomness
# `prepare_nodes_from_template` uses for node ids, so fetched graph content
# authored before a given call cannot predict or forge it.
uuid = { workspace = true }

[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"
Expand Down
Loading