- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
//! General-purpose (non-coding) scenario coverage. The same harness - 4
//! machinery — steering, abort, stop gate, compaction, MCP, permissions — - 5
//! exercised through research, writing, planning, and data-analysis flows - 6
//! instead of code tasks. - 7
- 8
use std::collections::{HashMap, VecDeque}; - 9
use std::path::Path; - 10
use std::sync::{Arc, Mutex}; - 11
use std::time::Duration; - 12
- 13
use tokio::sync::mpsc; - 14
use tokio_util::sync::CancellationToken; - 15
- 16
use tempfile::tempdir; - 17
- 18
use vak_agent::{Agent, AgentConfig, AgentEvent, SteeringQueues, TurnOutcome}; - 19
use vak_llm::stream; - 20
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 21
use vak_llm::{EventStream, LlmError, Provider}; - 22
use vak_mcp::{McpManager, McpTool, ServerConfig}; - 23
use vak_permission::{Mode, PermissionEngine}; - 24
use vak_session::types::{FrozenContract, SessionHeader}; - 25
use vak_session::{SessionLog, SessionPath}; - 26
use vak_tools::bash::BashTool; - 27
use vak_tools::read::ReadTool; - 28
use vak_tools::write::WriteTool; - 29
- 30
struct Scripted { - 31
responses: std::sync::Mutex<VecDeque<AssistantMessage>>, - 32
requests: std::sync::Mutex<Vec<ChatRequest>>, - 33
} - 34
- 35
#[async_trait::async_trait] - 36
impl Provider for Scripted { - 37
fn name(&self) -> &str { - 38
"scripted-general" - 39
} - 40
- 41
async fn stream( - 42
&self, - 43
request: ChatRequest, - 44
_cancel: CancellationToken, - 45
) -> Result<EventStream, LlmError> { - 46
self.requests.lock().unwrap().push(request); - 47
let next = self.responses.lock().unwrap().pop_front(); - 48
let (mut sink, rx) = stream::channel(64); - 49
match next { - 50
Some(m) => { - 51
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 52
sink.close_message(m).await; - 53
} - 54
None => { - 55
sink.close_error(LlmError::Parse("script exhausted".into())) - 56
.await - 57
} - 58
} - 59
Ok(rx) - 60
} - 61
} - 62
- 63
fn text_msg(t: &str) -> AssistantMessage { - 64
AssistantMessage { - 65
content: vec![ContentBlock::text(t)], - 66
stop_reason: StopReason::EndTurn, - 67
usage: Usage { - 68
input_tokens: 1, - 69
output_tokens: 1, - 70
..Default::default() - 71
}, - 72
model: "test-model".into(), - 73
response_id: None, - 74
} - 75
} - 76
- 77
fn tool_call(id: &str, name: &str, input: serde_json::Value) -> AssistantMessage { - 78
AssistantMessage { - 79
content: vec![ContentBlock::ToolUse { - 80
id: id.into(), - 81
name: name.into(), - 82
input, - 83
}], - 84
stop_reason: StopReason::ToolUse, - 85
usage: Usage::default(), - 86
model: "test-model".into(), - 87
response_id: None, - 88
} - 89
} - 90
- 91
fn setup( - 92
responses: Vec<AssistantMessage>, - 93
tools: Vec<Arc<dyn vak_tools::Tool>>, - 94
customize: impl FnOnce(&mut AgentConfig), - 95
) -> (Agent, Arc<Scripted>, tempfile::TempDir) { - 96
let dir = tempdir().unwrap(); - 97
let cwd = dir.path().to_path_buf(); - 98
let header = SessionHeader { - 99
agent: None, - 100
session_id: "general-flow".into(), - 101
created_at: chrono::Utc::now(), - 102
cwd: cwd.clone(), - 103
parent_session_id: None, - 104
contract_id: None, - 105
work_item_id: None, - 106
conversation: None, - 107
contract: FrozenContract { - 108
app_version: "0".into(), - 109
provider: "scripted".into(), - 110
model: "test-model".into(), - 111
route_ladder: Vec::new(), - 112
route_objective: String::new(), - 113
route_annotations: Vec::new(), - 114
system_prompt: "sys".into(), - 115
permission_mode: "full-access".into(), - 116
capabilities: Vec::new(), - 117
prompt_layers: Vec::new(), - 118
}, - 119
}; - 120
let home = cwd.join(".vak-home"); - 121
std::fs::create_dir_all(&home).unwrap(); - 122
let log = SessionLog::create( - 123
SessionPath::new_session_file(&home, &cwd, "general-flow"), - 124
header, - 125
) - 126
.unwrap(); - 127
let provider = Arc::new(Scripted { - 128
responses: std::sync::Mutex::new(responses.into_iter().collect()), - 129
requests: std::sync::Mutex::new(Vec::new()), - 130
}); - 131
let mut cfg = AgentConfig::new("sys"); - 132
cfg.model = "test-model".into(); - 133
cfg.tools = tools; - 134
cfg.mode = Mode::FullAccess; - 135
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 136
cfg.approver = Some(Arc::new(vak_agent::AutoApprove)); - 137
customize(&mut cfg); - 138
let agent = Agent::new(provider.clone(), log, cfg); - 139
(agent, provider, dir) - 140
} - 141
- 142
fn spawn_collector(mut rx: mpsc::Receiver<AgentEvent>) -> tokio::task::JoinHandle<Vec<AgentEvent>> { - 143
tokio::spawn(async move { - 144
let mut out = Vec::new(); - 145
while let Some(ev) = rx.recv().await { - 146
out.push(ev); - 147
} - 148
out - 149
}) - 150
} - 151
- 152
/// The user steers mid-run while the first step is still executing; the - 153
/// steer must enter the ledger before the next model call and shape the - 154
/// final artifact. - 155
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 156
async fn steering_redirects_trip_plan_mid_run() { - 157
let (mut agent, _provider, dir) = setup( - 158
vec![ - 159
tool_call("t1", "bash", serde_json::json!({"command": "sleep 0.6"})), - 160
tool_call( - 161
"t2", - 162
"write", - 163
serde_json::json!({ - 164
"path": "trip-plan.md", - 165
"content": "# Trip Plan\n\nTotal budget stays under $1500, with two full days in Kyoto.\n" - 166
}), - 167
), - 168
text_msg("plan finalized"), - 169
], - 170
vec![Arc::new(BashTool), Arc::new(WriteTool)], - 171
|_| {}, - 172
); - 173
- 174
let steering = Arc::new(SteeringQueues::new()); - 175
let pusher = { - 176
let steering = steering.clone(); - 177
tokio::spawn(async move { - 178
tokio::time::sleep(Duration::from_millis(150)).await; - 179
steering.push_steering("Budget is under $1500 total, and include two days in Kyoto."); - 180
}) - 181
}; - 182
- 183
let (ev_tx, ev_rx) = mpsc::channel(256); - 184
drop(spawn_collector(ev_rx)); - 185
- 186
let outcome = agent - 187
.run( - 188
"Plan our Japan trip and save it to trip-plan.md.", - 189
&steering, - 190
CancellationToken::new(), - 191
ev_tx, - 192
) - 193
.await; - 194
pusher.await.unwrap(); - 195
- 196
assert!( - 197
matches!(outcome, TurnOutcome::Completed { .. }), - 198
"got {outcome:?}" - 199
); - 200
- 201
// Model-visible input must be reconstructable from the ledger. - 202
let steered = agent - 203
.session - 204
.lock() - 205
.await - 206
.derive_messages() - 207
.iter() - 208
.any(|m| m.text_content().contains("under $1500")); - 209
assert!(steered, "steering message must be logged in the session"); - 210
- 211
let draft = std::fs::read_to_string(dir.path().join("trip-plan.md")).unwrap(); - 212
assert!( - 213
draft.contains("$1500") && draft.to_lowercase().contains("kyoto"), - 214
"final artifact must reflect the steering" - 215
); - 216
} - 217
- 218
/// Cancelling mid-run keeps everything the agent already wrote to disk. - 219
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 220
async fn abort_preserves_partial_research_notes() { - 221
let cancel = CancellationToken::new(); - 222
let (mut agent, _provider, dir) = setup( - 223
vec![ - 224
tool_call( - 225
"t1", - 226
"write", - 227
serde_json::json!({ - 228
"path": "notes-draft.md", - 229
"content": "# Research Notes\n\n- source A reviewed\n" - 230
}), - 231
), - 232
tool_call("t2", "bash", serde_json::json!({"command": "sleep 30"})), - 233
text_msg("never reached"), - 234
], - 235
vec![Arc::new(WriteTool), Arc::new(BashTool)], - 236
|_| {}, - 237
); - 238
- 239
let watcher_cancel = cancel.clone(); - 240
let watch_path = dir.path().join("notes-draft.md"); - 241
let watcher = tokio::spawn(async move { - 242
loop { - 243
if watch_path.exists() { - 244
tokio::time::sleep(Duration::from_millis(50)).await; - 245
watcher_cancel.cancel(); - 246
break; - 247
} - 248
tokio::time::sleep(Duration::from_millis(10)).await; - 249
} - 250
}); - 251
- 252
let outcome = agent - 253
.run( - 254
"Compile research notes into notes-draft.md.", - 255
&Default::default(), - 256
cancel, - 257
mpsc::channel(64).0, - 258
) - 259
.await; - 260
watcher.abort(); - 261
- 262
assert!( - 263
matches!(outcome, TurnOutcome::Aborted { .. }), - 264
"expected abort, got {outcome:?}" - 265
); - 266
let partial = - 267
std::fs::read_to_string(dir.path().join("notes-draft.md")).expect("partial file survives"); - 268
assert!(partial.contains("source A reviewed")); - 269
} - 270
- 271
/// A report task that demands verification cannot finish with zero executed - 272
/// commands; the built-in stop gate forces one continuation, then the run - 273
/// completes only after real verification ran. - 274
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 275
async fn stop_gate_blocks_premature_report_until_verified() { - 276
let (mut agent, _provider, dir) = setup( - 277
vec![ - 278
text_msg("Done. The report is ready."), - 279
tool_call( - 280
"t1", - 281
"bash", - 282
serde_json::json!({ - 283
"command": "printf 'Q1 total: 4200\\n' > report.md && grep -q 4200 report.md" - 284
}), - 285
), - 286
text_msg("Verified. Q1 total recorded as 4200 in report.md."), - 287
], - 288
vec![Arc::new(BashTool)], - 289
|_| {}, - 290
); - 291
- 292
let (ev_tx, ev_rx) = mpsc::channel(256); - 293
let collector = spawn_collector(ev_rx); - 294
- 295
let outcome = agent - 296
.run( - 297
"Compute the totals into report.md and verify the number appears in the file before you finish.", - 298
&Default::default(), - 299
CancellationToken::new(), - 300
ev_tx, - 301
) - 302
.await; - 303
let events = collector.await.unwrap(); - 304
- 305
match outcome { - 306
TurnOutcome::Completed { response } => { - 307
assert!(response.text_content().contains("Verified")); - 308
} - 309
other => panic!("expected completed after gate continuation, got {other:?}"), - 310
} - 311
- 312
let continuations = events - 313
.iter() - 314
.filter(|e| matches!(e, AgentEvent::StopHookContinuation { .. })) - 315
.count(); - 316
assert_eq!(continuations, 1, "gate must block exactly once"); - 317
- 318
// Raw ledger: a control nudge is scaffolding for the turn still in - 319
// progress and is dropped once the turn closes - 320
// (docs/design/68-context-engine.md §10) — this checks it was recorded - 321
// at all, not that the final projection still carries it. - 322
let guard_msgs = agent - 323
.session - 324
.lock() - 325
.await - 326
.message_chain() - 327
.iter() - 328
.filter(|(_, m)| m.text_content().contains("[stop-guard]")) - 329
.count(); - 330
assert_eq!(guard_msgs, 1, "guard continuation must be logged once"); - 331
- 332
// Agent bash works in the workspace, where the file tools read, so the - 333
// verified report is where the user and the next tool call look for it. - 334
let report = std::fs::read_to_string(dir.path().join("report.md")) - 335
.expect("verified report is in the workspace"); - 336
assert!(report.contains("4200")); - 337
} - 338
- 339
/// A long research session crosses the context trigger; compaction - 340
/// summarizes older turns into a ledger entry (never deleting them), the - 341
/// projection carries the summary forward, and the run completes. - 342
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 343
async fn compaction_during_long_research_session() { - 344
// Compaction works in whole closed turns - 345
// (docs/design/68-context-engine.md §10: a turn is never split), so - 346
// there has to be at least one closed turn for it to summarize away — - 347
// the still-open research turn below is never itself a compaction - 348
// candidate (§10: "current turn: every result verbatim, always"), so - 349
// the source files stay modest here; what pushes the budget over is - 350
// the seeded prior research, which compaction then drops. - 351
let big_a = "Research note on climate data points.\n".repeat(6); - 352
let big_b = "Research note on energy market shifts.\n".repeat(6); - 353
- 354
let dir = tempdir().unwrap(); - 355
let cwd = dir.path().to_path_buf(); - 356
let header = SessionHeader { - 357
agent: None, - 358
session_id: "general-flow".into(), - 359
created_at: chrono::Utc::now(), - 360
cwd: cwd.clone(), - 361
parent_session_id: None, - 362
contract_id: None, - 363
work_item_id: None, - 364
conversation: None, - 365
contract: FrozenContract { - 366
app_version: "0".into(), - 367
provider: "scripted".into(), - 368
model: "test-model".into(), - 369
route_ladder: Vec::new(), - 370
route_objective: String::new(), - 371
route_annotations: Vec::new(), - 372
system_prompt: "sys".into(), - 373
permission_mode: "full-access".into(), - 374
capabilities: Vec::new(), - 375
prompt_layers: Vec::new(), - 376
}, - 377
}; - 378
let home = cwd.join(".vak-home"); - 379
std::fs::create_dir_all(&home).unwrap(); - 380
let mut log = SessionLog::create( - 381
SessionPath::new_session_file(&home, &cwd, "general-flow"), - 382
header, - 383
) - 384
.unwrap(); - 385
// Big enough on their own to already be over the trigger threshold - 386
// before the real research turn starts, so incremental compaction - 387
// (docs/design/68-context-engine.md §4) fires on the very first plan — - 388
// consuming the FIRST scripted response below (the compaction summary) - 389
// rather than one meant for the real turn. Each seeded turn gets a - 390
// real `TurnCard` (as the turn-close hook would write): the planner - 391
// only ever collapses carded turns into a packet. - 392
let seed_filler = "prior research finding ".repeat(220); - 393
for i in 0..3 { - 394
let turn_id = log - 395
.append_message(vak_session::types::MessageRecord { - 396
message: vak_llm::types::Message::user_text(format!("earlier note {i}")), - 397
meta: None, - 398
}) - 399
.unwrap() - 400
.id; - 401
let answer = format!("acknowledged note {i}: {seed_filler}"); - 402
log.append_message(vak_session::types::MessageRecord { - 403
message: vak_llm::types::Message::assistant(vec![ContentBlock::text(&answer)]), - 404
meta: None, - 405
}) - 406
.unwrap(); - 407
let card = vak_session::TurnIndex::from_log(&log) - 408
.turn_by_id(&turn_id) - 409
.unwrap() - 410
.build_card("completed", answer, &|s| s.len() as u64 / 4); - 411
log.append_turn_card(vak_session::types::TurnCardRecord { turn_id, card }) - 412
.unwrap(); - 413
} - 414
let provider = Arc::new(Scripted { - 415
responses: std::sync::Mutex::new(VecDeque::from(vec![ - 416
text_msg( - 417
"Summary: prior research condensed; key climate and energy findings retained for the brief.", - 418
), - 419
tool_call("r1", "read", serde_json::json!({"path": "big-a.txt"})), - 420
tool_call("r2", "read", serde_json::json!({"path": "big-b.txt"})), - 421
tool_call( - 422
"w1", - 423
"write", - 424
serde_json::json!({ - 425
"path": "digest.txt", - 426
"content": "Digest: sources A and B agree on the retained findings.\n" - 427
}), - 428
), - 429
text_msg("digest written"), - 430
])), - 431
requests: std::sync::Mutex::new(Vec::new()), - 432
}); - 433
let mut cfg = AgentConfig::new("sys"); - 434
cfg.model = "test-model".into(); - 435
cfg.tools = vec![Arc::new(ReadTool), Arc::new(WriteTool)]; - 436
cfg.mode = Mode::FullAccess; - 437
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 438
cfg.approver = Some(Arc::new(vak_agent::AutoApprove)); - 439
// Small enough that the three heavily-padded seeded turns above cannot - 440
// all fit even as cards, forcing a packet on the very first plan. - 441
cfg.declared_window = 1500; - 442
cfg.max_output = 100; - 443
// The handoff-reset rescue is a different mechanism (Phase H) from - 444
// incremental compaction and would consume its own scripted response - 445
// if it fired; keep this test isolated to compaction alone. - 446
cfg.handoff_reset = false; - 447
let mut agent = Agent::new(provider.clone(), log, cfg); - 448
- 449
for (name, content) in [("big-a.txt", &big_a), ("big-b.txt", &big_b)] { - 450
std::fs::write(dir.path().join(name), content).unwrap(); - 451
} - 452
- 453
let (ev_tx, ev_rx) = mpsc::channel(4096); - 454
let collector = spawn_collector(ev_rx); - 455
- 456
let outcome = agent - 457
.run( - 458
"Read both source files and write digest.txt summarizing their key facts.", - 459
&Default::default(), - 460
CancellationToken::new(), - 461
ev_tx, - 462
) - 463
.await; - 464
let events = collector.await.unwrap(); - 465
- 466
match outcome { - 467
TurnOutcome::Completed { response } => { - 468
assert_eq!(response.text_content(), "digest written"); - 469
} - 470
other => panic!("expected completed after compaction, got {other:?}"), - 471
} - 472
let continuations = events - 473
.iter() - 474
.filter(|e| matches!(e, AgentEvent::ContextCompacting { .. })) - 475
.count(); - 476
assert_eq!(continuations, 1, "compaction must trigger exactly once"); - 477
let compacted = events.iter().find_map(|e| match e { - 478
AgentEvent::ContextCompacted { - 479
before_tokens, - 480
after_tokens, - 481
.. - 482
} => Some((*before_tokens, *after_tokens)), - 483
_ => None, - 484
}); - 485
let (before, after) = compacted.expect("compacted event"); - 486
assert!(after < before, "compaction must shrink the estimate"); - 487
- 488
// The summarizer ran as its own model call against the transcript - 489
// BEFORE the real research turn dispatched at all (the seeded prior - 490
// research alone was already over budget), and the whole run consumed - 491
// exactly the scripted trajectory: compaction + two reads + write + - 492
// final. - 493
{ - 494
let reqs = provider.requests.lock().unwrap(); - 495
assert_eq!(reqs.len(), 5, "compaction + two reads + write + final"); - 496
assert!( - 497
reqs[0] - 498
.system - 499
.as_deref() - 500
.unwrap_or("") - 501
.contains("compactor"), - 502
"first request must be the compaction call" - 503
); - 504
} - 505
- 506
// Append-only invariant: raw ledger still holds pre-compaction history - 507
// plus exactly one compaction entry; projection carries the summary. - 508
let session_file = - 509
SessionPath::new_session_file(&dir.path().join(".vak-home"), dir.path(), "general-flow"); - 510
let raw = std::fs::read_to_string(&session_file).unwrap(); - 511
let compaction_lines = raw - 512
.lines() - 513
.filter(|l| l.contains("\"kind\":\"compaction\"")) - 514
.count(); - 515
assert_eq!(compaction_lines, 1, "exactly one compaction entry expected"); - 516
assert!( - 517
raw.contains("prior research finding"), - 518
"original history must never be deleted from the ledger" - 519
); - 520
- 521
// The packet reached the model: every request after the compaction - 522
// call led with the summary. The plan-free projection, by contrast, - 523
// still carries the raw history — a packet is a cache for the plan - 524
// that asked for it, never a boundary in the ledger. - 525
{ - 526
let reqs = provider.requests.lock().unwrap(); - 527
for req in &reqs[1..] { - 528
assert!( - 529
req.messages[0] - 530
.text_content() - 531
.starts_with("<context_summary>"), - 532
"every request after compaction must lead with the packet" - 533
); - 534
} - 535
} - 536
let projected = agent.session.lock().await.derive_messages(); - 537
assert!( - 538
projected - 539
.iter() - 540
.any(|m| m.text_content().contains("prior research finding")), - 541
"the packet must not hide history from the plan-free projection" - 542
); - 543
- 544
let digest = std::fs::read_to_string(dir.path().join("digest.txt")).unwrap(); - 545
assert!(digest.contains("Digest:")); - 546
} - 547
- 548
/// A non-code lookup through the MCP meta-tool (list → call → artifact). - 549
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 550
async fn mcp_notes_lookup_informs_planning_answer() { - 551
let Ok(probe) = std::process::Command::new("python3") - 552
.arg("--version") - 553
.output() - 554
else { - 555
eprintln!("skipping: python3 unavailable"); - 556
return; - 557
}; - 558
assert!(probe.status.success(), "python3 must be runnable"); - 559
- 560
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/fake_mcp_server.py"); - 561
let mut servers = HashMap::new(); - 562
servers.insert( - 563
"notes".to_string(), - 564
ServerConfig { - 565
command: "python3".into(), - 566
args: vec![script.display().to_string()], - 567
env: Vec::new(), - 568
network: false, - 569
}, - 570
); - 571
let manager = Arc::new(McpManager::new(servers, dir_safe_cwd())); - 572
let aliases = Arc::new(Mutex::new(HashMap::new())); - 573
let aliases_after_list = aliases.clone(); - 574
let mcp_tool: Arc<dyn vak_tools::Tool> = Arc::new(McpTool::new(manager).with_catalog_observer( - 575
Arc::new(move |catalog| { - 576
let mut registered = aliases_after_list.lock().unwrap(); - 577
for (server, tools) in catalog { - 578
for tool in tools { - 579
registered.insert(tool.name.clone(), server.clone()); - 580
} - 581
} - 582
}), - 583
)); - 584
- 585
let (mut agent, _provider, dir) = setup( - 586
vec![ - 587
tool_call("m1", "mcp", serde_json::json!({"action": "list"})), - 588
tool_call("m2", "echo", serde_json::json!({"text": "flights booked"})), - 589
tool_call( - 590
"w1", - 591
"write", - 592
serde_json::json!({ - 593
"path": "answer.md", - 594
"content": "# Status\n\nMCP says: echo: flights booked\n" - 595
}), - 596
), - 597
text_msg("answered via mcp"), - 598
], - 599
vec![mcp_tool, Arc::new(WriteTool)], - 600
|config| { - 601
config.mcp_tool_index = aliases.clone(); - 602
}, - 603
); - 604
- 605
let outcome = agent - 606
.run( - 607
"Ask the notes service about our booking and record the reply in answer.md.", - 608
&Default::default(), - 609
CancellationToken::new(), - 610
mpsc::channel(256).0, - 611
) - 612
.await; - 613
- 614
assert!( - 615
matches!(outcome, TurnOutcome::Completed { .. }), - 616
"got {outcome:?}" - 617
); - 618
let answer = std::fs::read_to_string(dir.path().join("answer.md")).unwrap(); - 619
assert!( - 620
answer.contains("echo: flights booked"), - 621
"artifact must carry the MCP result" - 622
); - 623
} - 624
- 625
#[tokio::test] - 626
async fn mcp_call_omitting_server_auto_resolves_and_completes() { - 627
let Ok(probe) = std::process::Command::new("python3") - 628
.arg("--version") - 629
.output() - 630
else { - 631
eprintln!("skipping: python3 unavailable"); - 632
return; - 633
}; - 634
assert!(probe.status.success(), "python3 must be runnable"); - 635
- 636
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/fake_mcp_server.py"); - 637
let mut servers = HashMap::new(); - 638
servers.insert( - 639
"notes".to_string(), - 640
ServerConfig { - 641
command: "python3".into(), - 642
args: vec![script.display().to_string()], - 643
env: Vec::new(), - 644
network: false, - 645
}, - 646
); - 647
let manager = Arc::new(McpManager::new(servers, dir_safe_cwd())); - 648
let aliases = Arc::new(Mutex::new(HashMap::new())); - 649
let aliases_map = aliases.clone(); - 650
let mcp_tool: Arc<dyn vak_tools::Tool> = Arc::new(McpTool::new(manager).with_catalog_observer( - 651
Arc::new(move |catalog| { - 652
let mut registered = aliases_map.lock().unwrap(); - 653
for (server, tools) in catalog { - 654
for tool in tools { - 655
registered.insert(tool.name.clone(), server.clone()); - 656
} - 657
} - 658
}), - 659
)); - 660
- 661
// Pre-populate alias as would be done from admitted capability inventory - 662
aliases - 663
.lock() - 664
.unwrap() - 665
.insert("echo".into(), "notes".into()); - 666
- 667
let (mut agent, _provider, dir) = setup( - 668
vec![ - 669
// Model calls `mcp` with action=call and tool=echo, but completely OMITS `server`! - 670
tool_call( - 671
"m1", - 672
"mcp", - 673
serde_json::json!({ - 674
"action": "call", - 675
"tool": "echo", - 676
"arguments": {"text": "flights booked"} - 677
}), - 678
), - 679
tool_call( - 680
"w1", - 681
"write", - 682
serde_json::json!({ - 683
"path": "auto_resolved.md", - 684
"content": "# Result\n\nAuto-resolved echo succeeded\n" - 685
}), - 686
), - 687
text_msg("auto-resolved and done"), - 688
], - 689
vec![mcp_tool, Arc::new(WriteTool)], - 690
|config| { - 691
config.mcp_tool_index = aliases.clone(); - 692
}, - 693
); - 694
- 695
let outcome = agent - 696
.run( - 697
"Echo flights booked without specifying server", - 698
&Default::default(), - 699
CancellationToken::new(), - 700
mpsc::channel(256).0, - 701
) - 702
.await; - 703
- 704
assert!( - 705
matches!(outcome, TurnOutcome::Completed { .. }), - 706
"got {outcome:?}" - 707
); - 708
assert!(dir.path().join("auto_resolved.md").is_file()); - 709
} - 710
- 711
fn dir_safe_cwd() -> std::path::PathBuf { - 712
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) - 713
} - 714
- 715
/// In read-only mode a note-taking write is denied with a typed reason; the - 716
/// model adapts by delivering the content inline and nothing hits disk. - 717
#[tokio::test] - 718
async fn read_only_mode_denies_note_writes_and_agent_reports_inline() { - 719
let (mut agent, _provider, dir) = setup( - 720
vec![ - 721
tool_call( - 722
"t1", - 723
"write", - 724
serde_json::json!({ - 725
"path": "field-notes.md", - 726
"content": "# Field Notes\n\nObservation one recorded on site.\n" - 727
}), - 728
), - 729
text_msg( - 730
"Saving is unavailable in read-only mode. Field notes inline: Observation one recorded on site.", - 731
), - 732
], - 733
vec![Arc::new(WriteTool)], - 734
|cfg| { - 735
cfg.mode = Mode::ReadOnly; - 736
}, - 737
); - 738
- 739
let outcome = agent - 740
.run( - 741
"Record today's field observation in field-notes.md.", - 742
&Default::default(), - 743
CancellationToken::new(), - 744
mpsc::channel(64).0, - 745
) - 746
.await; - 747
- 748
match outcome { - 749
TurnOutcome::Completed { response } => { - 750
assert!(response.text_content().contains("inline")); - 751
} - 752
other => panic!("denied write must not fail the run, got {other:?}"), - 753
} - 754
assert!( - 755
!dir.path().join("field-notes.md").exists(), - 756
"denied write must never touch disk" - 757
); - 758
// Raw ledger: the closed turn's result is a trace line in the - 759
// projection now (docs/design/68-context-engine.md §10); this checks - 760
// what actually got recorded. - 761
let denied = agent - 762
.session - 763
.lock() - 764
.await - 765
.message_chain() - 766
.iter() - 767
.flat_map(|(_, m)| m.content.iter()) - 768
.find_map(|b| match b { - 769
ContentBlock::ToolResult { - 770
content, is_error, .. - 771
} => Some((content.clone(), *is_error)), - 772
_ => None, - 773
}) - 774
.expect("denied call must produce a tool result"); - 775
assert!(denied.1, "denied write is an error result"); - 776
assert!(denied.0.contains("read-only mode denies")); - 777
} - 778
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.