- 1
//! End-to-end guards for the capability lifecycle - 2
//! (docs/design/41-capability-registry.md). - 3
//! - 4
//! Every test here pins a behaviour that was broken in a way no error - 5
//! surfaced: the agent did not fail, it answered from memory and sounded - 6
//! certain. That invisibility is why these are integration tests rather than - 7
//! notes in a changelog. - 8
- 9
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 10
- 11
use std::collections::{BTreeSet, VecDeque}; - 12
use std::path::Path; - 13
use std::sync::{Arc, Mutex}; - 14
- 15
use tokio_util::sync::CancellationToken; - 16
- 17
use vak_core::Core; - 18
use vak_core::capability::registry::{CapabilityProvider, Declaration}; - 19
use vak_core::capability::{CapabilityId, CapabilityRegistry, Domain, Origin, Serves}; - 20
use vak_llm::stream; - 21
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, Usage}; - 22
use vak_llm::{EventStream, LlmError, Provider}; - 23
use vak_session::types::CapabilityKind; - 24
- 25
// ---------------------------------------------------------------- fakes --- - 26
- 27
struct Fake { - 28
declarations: Mutex<Vec<Declaration>>, - 29
} - 30
- 31
impl Fake { - 32
fn new(declarations: Vec<Declaration>) -> Arc<Self> { - 33
Arc::new(Fake { - 34
declarations: Mutex::new(declarations), - 35
}) - 36
} - 37
} - 38
- 39
#[async_trait::async_trait] - 40
impl CapabilityProvider for Fake { - 41
fn declare(&self) -> Vec<Declaration> { - 42
self.declarations.lock().unwrap().clone() - 43
} - 44
} - 45
- 46
fn declaration(name: &str, kind: CapabilityKind) -> Declaration { - 47
Declaration { - 48
id: CapabilityId::new(kind, name), - 49
origin: Origin::Workspace, - 50
summary: String::new(), - 51
serves: Serves::Undeclared, - 52
digest: None, - 53
source: None, - 54
configuration: serde_json::Value::Null, - 55
} - 56
} - 57
- 58
struct Scripted { - 59
responses: Mutex<VecDeque<AssistantMessage>>, - 60
} - 61
- 62
#[async_trait::async_trait] - 63
impl Provider for Scripted { - 64
fn name(&self) -> &str { - 65
"scripted" - 66
} - 67
- 68
async fn stream( - 69
&self, - 70
_request: ChatRequest, - 71
_cancel: CancellationToken, - 72
) -> Result<EventStream, LlmError> { - 73
let next = self.responses.lock().unwrap().pop_front(); - 74
let (mut sink, rx) = stream::channel(64); - 75
match next { - 76
Some(m) => { - 77
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 78
sink.close_message(m).await; - 79
} - 80
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 81
} - 82
Ok(rx) - 83
} - 84
} - 85
- 86
fn reply(content: ContentBlock, stop: vak_llm::types::StopReason) -> AssistantMessage { - 87
AssistantMessage { - 88
content: vec![content], - 89
stop_reason: stop, - 90
usage: Usage::default(), - 91
model: "test-model".into(), - 92
response_id: None, - 93
} - 94
} - 95
- 96
fn spawns(marker: &Path) -> usize { - 97
std::fs::read_to_string(marker) - 98
.map(|text| text.lines().count()) - 99
.unwrap_or(0) - 100
} - 101
- 102
// ------------------------------------------------------------- the case --- - 103
- 104
/// A configured MCP server is started by demand and nothing else - 105
/// (AGENTS.md invariant 25). Admission, reconciliation and prompt assembly - 106
/// all run without starting it; the model's first `mcp` call starts it once; - 107
/// and what that call learned reaches the next turn's prompt with no restart. - 108
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 109
async fn an_mcp_server_starts_on_demand_and_its_catalog_reaches_the_next_turn() { - 110
vak_config::paths::isolate_home_for_tests(); - 111
let dir = tempfile::tempdir().unwrap(); - 112
let cwd = dir.path().join("workspace"); - 113
std::fs::create_dir_all(&cwd).unwrap(); - 114
let marker = dir.path().join("spawns"); - 115
let script = Path::new(env!("CARGO_MANIFEST_DIR")) - 116
.join("../../scripts/fake_mcp_server.py") - 117
.canonicalize() - 118
.unwrap(); - 119
- 120
let core = Core::new_with_trust(cwd, true).unwrap(); - 121
core.set_sessions_home(dir.path().join("home")); - 122
core.set_permission_mode(vak_config::PermissionMode::FullAccess); - 123
let mut mcp = vak_config::McpConfig::default(); - 124
mcp.servers.insert( - 125
"fake".into(), - 126
vak_config::McpServerConfig { - 127
command: "sh".into(), - 128
args: vec![ - 129
"-c".into(), - 130
format!( - 131
"echo started >> '{}'; exec python3 '{}'", - 132
marker.display(), - 133
script.display() - 134
), - 135
], - 136
env: Default::default(), - 137
network: false, - 138
serves: Vec::new(), - 139
}, - 140
); - 141
core.set_mcp_servers(mcp); - 142
- 143
core.admitted_capabilities().await; - 144
core.reconcile_capabilities().await; - 145
let before = core.system_prompt(); - 146
assert!(before.contains("\n- fake\n"), "named, not yet listed"); - 147
assert_eq!( - 148
spawns(&marker), - 149
0, - 150
"admission and prompt assembly start nothing" - 151
); - 152
- 153
core.set_provider_instance(Arc::new(Scripted { - 154
responses: Mutex::new(VecDeque::from(vec![ - 155
reply( - 156
ContentBlock::ToolUse { - 157
id: "m1".into(), - 158
name: "mcp".into(), - 159
input: serde_json::json!({"action": "list", "server": "fake"}), - 160
}, - 161
vak_llm::types::StopReason::ToolUse, - 162
), - 163
reply( - 164
ContentBlock::text("listed"), - 165
vak_llm::types::StopReason::EndTurn, - 166
), - 167
])), - 168
})); - 169
let session = core.start_session().await.unwrap(); - 170
let (events, _rx) = tokio::sync::mpsc::channel(256); - 171
let (outcome, _) = core - 172
.run_turn_with( - 173
session, - 174
"what can the fake server do?", - 175
CancellationToken::new(), - 176
None, - 177
None, - 178
None, - 179
events, - 180
) - 181
.await - 182
.unwrap(); - 183
assert!(matches!(outcome, vak_agent::TurnOutcome::Completed { .. })); - 184
assert_eq!(spawns(&marker), 1, "the model's call started it, once"); - 185
- 186
core.reconcile_capabilities().await; - 187
assert!( - 188
core.system_prompt() - 189
.contains("- fake: echo, boom, secret_result"), - 190
"the observed catalog reaches the next prompt: {}", - 191
core.system_prompt() - 192
); - 193
assert_eq!( - 194
spawns(&marker), - 195
1, - 196
"learning the catalog started nothing new" - 197
); - 198
} - 199
- 200
// ------------------------------------------------------------ lifecycle --- - 201
- 202
/// Add, edit and remove, on a registry that is never restarted. - 203
#[tokio::test] - 204
async fn capabilities_can_be_added_edited_and_removed_while_running() { - 205
let provider = Fake::new(vec![declaration("read", CapabilityKind::Tool)]); - 206
let (registry, _hints) = CapabilityRegistry::new(provider.clone()); - 207
registry.reconcile().await; - 208
let first = registry.current().await.epoch; - 209
- 210
// --- added ----------------------------------------------------------- - 211
provider - 212
.declarations - 213
.lock() - 214
.unwrap() - 215
.push(declaration("pdf", CapabilityKind::Skill)); - 216
let delta = registry.reconcile().await.expect("adding is a change"); - 217
assert_eq!(delta.added.len(), 1); - 218
assert!(delta.describe().contains("now available")); - 219
let second = registry.current().await.epoch; - 220
assert!(second > first, "a change publishes a new epoch"); - 221
- 222
// --- edited ---------------------------------------------------------- - 223
provider - 224
.declarations - 225
.lock() - 226
.unwrap() - 227
.iter_mut() - 228
.filter(|d| d.id.name == "pdf") - 229
.for_each(|d| d.digest = Some("rewritten".into())); - 230
let delta = registry.reconcile().await.expect("editing is a change"); - 231
assert_eq!(delta.updated.len(), 1); - 232
assert!(registry.current().await.epoch > second); - 233
- 234
// --- removed --------------------------------------------------------- - 235
provider - 236
.declarations - 237
.lock() - 238
.unwrap() - 239
.retain(|d| d.id.name != "pdf"); - 240
let delta = registry.reconcile().await.expect("removing is a change"); - 241
assert_eq!(delta.removed.len(), 1); - 242
assert!( - 243
delta.describe().contains("no longer available"), - 244
"removal is announced, not silent" - 245
); - 246
} - 247
- 248
/// Reconciling an unchanged world must publish nothing. - 249
/// - 250
/// A loop that ran every ten seconds for three weeks and republished each - 251
/// time would churn every live session's prompt and defeat the point. - 252
#[tokio::test] - 253
async fn a_quiet_world_never_churns_the_epoch() { - 254
let provider = Fake::new(vec![declaration("read", CapabilityKind::Tool)]); - 255
let (registry, _hints) = CapabilityRegistry::new(provider); - 256
registry.reconcile().await; - 257
let epoch = registry.current().await.epoch; - 258
for _ in 0..25 { - 259
assert!(registry.reconcile().await.is_none()); - 260
} - 261
assert_eq!(registry.current().await.epoch, epoch); - 262
} - 263
- 264
// ----------------------------------------------------------- revocation --- - 265
- 266
/// Revocation is immediate and independent of any epoch. An operator - 267
/// disabling a compromised plugin must not wait for a long turn to finish. - 268
#[tokio::test] - 269
async fn revocation_applies_immediately_and_restoration_republishes() { - 270
let provider = Fake::new(vec![declaration("bash", CapabilityKind::Tool)]); - 271
let (registry, _hints) = CapabilityRegistry::new(provider); - 272
registry.reconcile().await; - 273
let id = CapabilityId::new(CapabilityKind::Tool, "bash"); - 274
- 275
registry.revoke(id.clone(), "operator disabled").await; - 276
assert_eq!( - 277
registry.revocation(&id).await.as_deref(), - 278
Some("operator disabled"), - 279
"visible at dispatch with no reconcile in between" - 280
); - 281
- 282
registry.reconcile().await; - 283
assert_eq!(registry.current().await.usable().count(), 0); - 284
- 285
registry.restore(&id).await; - 286
registry.reconcile().await; - 287
assert_eq!(registry.current().await.usable().count(), 1); - 288
} - 289
- 290
// -------------------------------------------------------------- domains --- - 291
- 292
/// A domain this build has never heard of must round-trip and match itself, - 293
/// so a plugin with its own vocabulary is not silently flattened. - 294
#[test] - 295
fn an_unknown_domain_survives_and_matches() { - 296
let parsed = Domain::parse("procurement"); - 297
assert_eq!(parsed, Domain::Custom("procurement".into())); - 298
let serves = Serves::declared([parsed.clone()]); - 299
assert!(serves.serves_any(&BTreeSet::from([parsed]))); - 300
assert!(!serves.serves_any(&BTreeSet::from([Domain::Web]))); - 301
} - 302
- 303
/// A changed secret changes the server's declared digest, which is a change - 304
/// to the world: admission sees pending work and the next turn binds the new - 305
/// epoch, with no restart. - 306
#[tokio::test] - 307
async fn a_resolved_secret_is_a_pending_change_at_the_next_admission() { - 308
let mut decl = declaration("search_srv", CapabilityKind::McpServer); - 309
decl.digest = Some("unresolved_hash".into()); - 310
let provider = Fake::new(vec![decl.clone()]); - 311
let (registry, _hints) = CapabilityRegistry::new(provider.clone()); - 312
registry.reconcile().await; - 313
let first = registry.current().await.epoch; - 314
- 315
decl.digest = Some("resolved_hash_with_key".into()); - 316
decl.configuration = serde_json::json!({"tools": [{"name": "search"}]}); - 317
*provider.declarations.lock().unwrap() = vec![decl]; - 318
assert!(registry.has_pending_changes().await); - 319
assert!(registry.reconcile().await.is_some()); - 320
let current = registry.current().await; - 321
assert!(current.epoch > first); - 322
- 323
let inventory = current.mcp_inventory(); - 324
let empty_policy = vak_config::ChannelPolicy::default(); - 325
let builtins: BTreeSet<String> = ["read".to_string(), "glob".to_string()].into(); - 326
let tc = vak_core::capability::TurnCapabilities::build(&vak_core::capability::TurnProbe { - 327
capabilities: ¤t, - 328
revoked_ids: BTreeSet::new(), - 329
channel_policy: &empty_policy, - 330
reach_standings: &[], - 331
mcp_inventory: &inventory, - 332
builtin_names: &builtins, - 333
}); - 334
assert_eq!( - 335
tc.mcp_tool_index.get("search").map(String::as_str), - 336
Some("search_srv"), - 337
"a bare `search` call resolves to its server in turn 2" - 338
); - 339
assert!(tc.mcp_server_names.contains(&"search_srv".to_string())); - 340
} - 341
- 342
// -------------------------------------------------------- agent network --- - 343
- 344
/// Inter-agent messaging is offered only while an operator has authorized - 345
/// this workspace on the broker, and withdrawn when that authorization is. - 346
#[tokio::test] - 347
async fn agent_network_is_offered_only_while_the_workspace_is_authorized() { - 348
vak_config::paths::isolate_home_for_tests(); - 349
let dir = tempfile::tempdir().unwrap(); - 350
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 351
let offered = |core: &Core| core.tool_names().iter().any(|n| n == "agent_network"); - 352
assert!(!offered(&core), "off by default"); - 353
- 354
let broker = core.agent_network_broker(); - 355
let capability = broker.register( - 356
core.cwd().canonicalize().unwrap().display().to_string(), - 357
vak_core::agent_network::WorkspaceNetworkPolicy { - 358
enabled: true, - 359
allowed_peers: ["peer-workspace".to_string()].into(), - 360
max_message_bytes: 1024, - 361
}, - 362
); - 363
assert!(offered(&core)); - 364
- 365
assert!(broker.revoke(&capability)); - 366
assert!( - 367
!offered(&core), - 368
"revoking the registration withdraws the tool" - 369
); - 370
} - 371
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.