- 19464
Some("telegram:12345") - 19465
); - 19466
assert_eq!(state.gateway.approval_timeout().as_secs(), 60); - 19467
- 19468
// And on disk, so the next process starts the same way. - 19469
let reloaded = vak_config::load_with_trust(dir.path(), true).unwrap(); - 19470
assert_eq!(reloaded.gateway.approvals, "forward"); - 19471
assert_eq!(reloaded.gateway.approver.as_deref(), Some("telegram:12345")); - 19472
} - 19473
- 19474
/// The loader degrades an unbacked `forward` to `deny` with a warning, - 19475
/// which is right for a bad file and wrong for a button press: the - 19476
/// operator would see success and get the opposite setting. - 19477
#[tokio::test] - 19478
async fn forwarding_without_a_chat_is_refused_rather_than_silently_denied() { - 19479
let dir = tempfile::tempdir().unwrap(); - 19480
let state = control_state(dir.path()); - 19481
for approver in [None, Some("not-a-chat-address".to_string())] { - 19482
let response = put_gateway_approvals( - 19483
State(state.clone()), - 19484
Json(GatewayApprovalsBody { - 19485
mode: "forward".into(), - 19486
approver, - 19487
timeout_secs: None, - 19488
scope: None, - 19489
}), - 19490
) - 19491
.await; - 19492
assert_eq!(response.status(), StatusCode::BAD_REQUEST); - 19493
} - 19494
assert_eq!(state.gateway.approvals_mode(), "deny", "nothing changed"); - 19495
} - 19496
- 19497
/// Going back to `deny` must not leave the old target behind for a later - 19498
/// `forward` to pick up silently. - 19499
#[tokio::test] - 19500
async fn returning_to_deny_clears_the_approver() { - 19501
let dir = tempfile::tempdir().unwrap(); - 19502
let state = control_state(dir.path()); - 19503
let ok = |body| put_gateway_approvals(State(state.clone()), Json(body)); - 19504
assert_eq!( - 19505
ok(GatewayApprovalsBody { - 19506
mode: "forward".into(), - 19507
approver: Some("telegram:1".into()), - 19508
timeout_secs: None, - 19509
scope: None, - 19510
}) - 19511
.await - 19512
.status(), - 19513
StatusCode::OK - 19514
); - 19515
assert_eq!( - 19516
ok(GatewayApprovalsBody { - 19517
mode: "deny".into(), - 19518
approver: None, - 19519
timeout_secs: None, - 19520
scope: None, - 19521
}) - 19522
.await - 19523
.status(), - 19524
StatusCode::OK - 19525
); - 19526
assert!(state.gateway.approver_target().is_none()); - 19527
let reloaded = vak_config::load_with_trust(dir.path(), true).unwrap(); - 19528
assert!(reloaded.gateway.approver.is_none()); - 19529
} - 19530
- 19531
// ---- permission rules (finding 02) ------------------------------------- - 19532
- 19533
#[tokio::test] - 19534
async fn rules_are_written_validated_and_applied_to_the_next_engine() { - 19535
let dir = tempfile::tempdir().unwrap(); - 19536
let state = control_state(dir.path()); - 19537
let args = serde_json::json!({ "command": "rm -rf /" }); - 19538
- 19539
let response = put_permission_rules( - 19540
State(state.clone()), - 19541
Json(PermissionRulesBody { - 19542
allow: None, - 19543
ask: None, - 19544
deny: Some(vec!["Bash(rm *)".into()]), - 19545
scope: None, - 19546
agent: None, - 19547
}), - 19548
) - 19549
.await; - 19550
assert_eq!(response.status(), StatusCode::OK); - 19551
- 19552
let engine = state.core.build_permission_engine(&[]).unwrap(); - 19553
assert!(matches!( - 19554
engine.evaluate( - 19555
"bash", - 19556
&args, - 19557
vak_permission::Mode::FullAccess, - 19558
state.core.cwd() - 19559
), - 19560
vak_permission::Decision::Deny { .. } - 19561
)); - 19562
} - 19563
- 19564
/// A half-applied rule set is a permission decision nobody chose, so one - 19565
/// bad spec rejects the whole request and writes nothing. - 19566
#[tokio::test] - 19567
async fn one_malformed_rule_rejects_the_whole_write() { - 19568
let dir = tempfile::tempdir().unwrap(); - 19569
let state = control_state(dir.path()); - 19570
let response = put_permission_rules( - 19571
State(state.clone()), - 19572
Json(PermissionRulesBody { - 19573
allow: None, - 19574
ask: None, - 19575
deny: Some(vec!["Bash(git *)".into(), "Bash((((".into()]), - 19576
scope: None, - 19577
agent: None, - 19578
}), - 19579
) - 19580
.await; - 19581
assert_eq!(response.status(), StatusCode::BAD_REQUEST); - 19582
let (_, _, deny) = state.core.effective_permission_rules(); - 19583
assert!(deny.is_empty(), "nothing may be written: {deny:?}"); - 19584
} - 19585
- 19586
// ---- global writes shadowed by a project pin (finding 07) -------------- - 19587
// - 19588
// Those tests write the Shared layer, which every test in this binary - 19589
// reads through `Core::new`, so they run in a binary of their own: - 19590
// `tests/shared_config_layer.rs`. No test here may write that layer. - 19591
- 19592
#[tokio::test] - 19593
async fn cross_process_mode_refresh_revokes_live_capability_before_apply() { - 19594
vak_config::paths::isolate_home_for_tests(); - 19595
let dir = tempfile::tempdir().unwrap(); - 19596
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 19597
core.set_sessions_home(dir.path().join("home")); - 19598
let state = AppState::new(core.clone()); - 19599
let session = core.start_session().await.unwrap(); - 19600
let id = session.header().unwrap().session_id.clone(); - 19601
let handle = register_handle(&state, id, session, core.cwd().clone(), core.clone()); - 19602
assert!(!handle.cancel.lock().unwrap().is_cancelled()); - 19603
- 19604
vak_config::persist_project_preferences( - 19605
dir.path(), - 19606
None, - 19607
None, - 19608
Some(19), - 19609
Some(vak_config::PermissionMode::ReadOnly), - 19610
None, - 19611
None, - 19612
) - 19613
.unwrap(); - 19614
refresh_control_plane(&state); - 19615
- 19616
assert_eq!(core.effective_max_turns(), 19); - 19617
assert_eq!( - 19618
core.effective_permission_mode(), - 19619
vak_config::PermissionMode::ReadOnly - 19620
); - 19621
assert!(handle.cancel.lock().unwrap().is_cancelled()); - 19622
} - 19623
- 19624
/// The bug this locks in: before `effective_memory_*` existed, - 19625
/// `Core::config().memory.*` was read directly at every call site, so - 19626
/// a live PATCH — or another process persisting a change to disk — - 19627
/// silently did nothing until the process restarted. Mirrors - 19628
/// `cross_process_mode_refresh_revokes_live_capability_before_apply`'s - 19629
/// shape for the memory tier instead of permission mode. - 19630
#[tokio::test] - 19631
async fn cross_process_memory_refresh_takes_effect_without_restart() { - 19632
let dir = tempfile::tempdir().unwrap(); - 19633
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 19634
core.set_sessions_home(dir.path().join("home")); - 19635
assert!(core.effective_memory_search_enabled(), "default is on"); - 19636
- 19637
vak_config::persist_project_memory_prefs(dir.path(), Some(false), None, None, None) - 19638
.unwrap(); - 19639
core.refresh_persisted_preferences().unwrap(); - 19640
- 19641
assert!( - 19642
!core.effective_memory_search_enabled(), - 19643
"a disk change from another process must reach an already-running Core" - 19644
); - 19645
// Untouched flags keep their default, proving the write was - 19646
// scoped to exactly the one field this call named. - 19647
assert!(core.effective_memory_write_enabled()); - 19648
} - 19649
- 19650
/// `PATCH /config` end to end: persists to disk, applies live - 19651
/// immediately (no restart), and a field the PATCH didn't mention - 19652
/// keeps its current value rather than reverting to whatever was on - 19653
/// disk before this call. - 19654
#[tokio::test] - 19655
async fn patch_config_memory_flags_apply_live_and_persist() { - 19656
let dir = tempfile::tempdir().unwrap(); - 19657
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 19658
core.set_sessions_home(dir.path().join("home")); - 19659
let state = AppState::new(core.clone()); - 19660
- 19661
let response = patch_config( - 19662
State(state.clone()), - 19663
Json(ConfigPatch { - 19664
memory_write_enabled: Some(false), - 19665
..Default::default() - 19666
}), - 19667
) - 19668
.await; - 19669
assert_eq!(response.status(), StatusCode::OK); - 19670
- 19671
assert!( - 19672
!core.effective_memory_write_enabled(), - 19673
"must apply live without a restart" - 19674
); - 19675
assert!( - 19676
core.effective_memory_search_enabled(), - 19677
"a field this PATCH never mentioned must keep its value" - 19678
); - 19679
- 19680
// Persisted to disk, not just the in-process override — a fresh - 19681
// Core over the same cwd sees it too. - 19682
let fresh = Core::new(dir.path().to_path_buf()).unwrap(); - 19683
fresh.set_sessions_home(dir.path().join("home")); - 19684
assert!(!fresh.effective_memory_write_enabled()); - 19685
} - 19686
- 19687
/// Same live-without-restart guarantee as memory, for the `workers` - 19688
/// toggle newly surfaced in the admin console's Settings page — it was - 19689
/// previously read from `Core::config()` directly at both call sites, - 19690
/// so a PATCH would have silently done nothing. - 19691
#[tokio::test] - 19692
async fn patch_config_workers_applies_live_and_persists() { - 19693
let dir = tempfile::tempdir().unwrap(); - 19694
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 19695
core.set_sessions_home(dir.path().join("home")); - 19696
let state = AppState::new(core.clone()); - 19697
assert!(core.effective_workers(), "default is on"); - 19698
- 19699
let response = patch_config( - 19700
State(state.clone()), - 19701
Json(ConfigPatch { - 19702
workers: Some(false), - 19703
..Default::default() - 19704
}), - 19705
) - 19706
.await; - 19707
assert_eq!(response.status(), StatusCode::OK); - 19708
assert!( - 19709
!core.effective_workers(), - 19710
"must apply live without a restart" - 19711
); - 19712
- 19713
let fresh = Core::new(dir.path().to_path_buf()).unwrap(); - 19714
fresh.set_sessions_home(dir.path().join("home")); - 19715
assert!(!fresh.effective_workers(), "must be persisted to disk too"); - 19716
} - 19717
- 19718
/// `PATCH /finops` sets a cap live and persists it; an explicit `null` - 19719
/// clears a previously-set cap rather than being indistinguishable - 19720
/// from the field being absent (the exact bug `deserialize_present` - 19721
/// exists to prevent, exercised here for a fresh field). - 19722
#[tokio::test] - 19723
async fn patch_finops_sets_and_clears_caps_live_and_persisted() { - 19724
let dir = tempfile::tempdir().unwrap(); - 19725
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 19726
core.set_sessions_home(dir.path().join("home")); - 19727
let state = AppState::new(core.clone()); - 19728
assert_eq!(core.effective_finops_max_run_usd(), None); - 19729
- 19730
let status = patch_finops( - 19731
State(state.clone()), - 19732
Json(FinopsPatch { - 19733
max_run_usd: Some(Some(5.0)), - 19734
max_day_usd: None, - 19735
agent: None, - 19736
}), - 19737
) - 19738
.await; - 19739
assert_eq!(status.status(), StatusCode::OK); - 19740
assert_eq!(core.effective_finops_max_run_usd(), Some(5.0)); - 19741
- 19742
let fresh = Core::new(dir.path().to_path_buf()).unwrap(); - 19743
fresh.set_sessions_home(dir.path().join("home")); - 19744
assert_eq!( - 19745
fresh.effective_finops_max_run_usd(), - 19746
Some(5.0), - 19747
"must be persisted to disk too" - 19748
); - 19749
- 19750
// Explicit null clears it back to "no cap". - 19751
let status = patch_finops( - 19752
State(state.clone()), - 19753
Json(FinopsPatch { - 19754
max_run_usd: Some(None), - 19755
max_day_usd: None, - 19756
agent: None, - 19757
}), - 19758
) - 19759
.await; - 19760
assert_eq!(status.status(), StatusCode::OK); - 19761
assert_eq!( - 19762
core.effective_finops_max_run_usd(), - 19763
None, - 19764
"explicit null must clear the cap, not be a no-op" - 19765
); - 19766
} - 19767
- 19768
#[tokio::test] - 19769
async fn patch_finops_rejects_negative_cap() { - 19770
let dir = tempfile::tempdir().unwrap(); - 19771
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 19772
core.set_sessions_home(dir.path().join("home")); - 19773
let state = AppState::new(core.clone()); - 19774
let status = patch_finops( - 19775
State(state), - 19776
Json(FinopsPatch { - 19777
max_run_usd: Some(Some(-1.0)), - 19778
max_day_usd: None, - 19779
agent: None, - 19780
}), - 19781
) - 19782
.await; - 19783
assert_eq!(status.status(), StatusCode::BAD_REQUEST); - 19784
} - 19785
} - 19786
- 19787
#[cfg(test)] - 19788
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 19789
mod sandbox_promotion_tests { - 19790
use super::*; - 19791
use std::collections::VecDeque; - 19792
use vak_llm::stream; - 19793
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 19794
use vak_llm::{EventStream, LlmError}; - 19795
- 19796
struct RevisionProvider { - 19797
replies: Mutex<VecDeque<AssistantMessage>>, - 19798
} - 19799
- 19800
#[async_trait::async_trait] - 19801
impl Provider for RevisionProvider { - 19802
fn name(&self) -> &str { - 19803
"revision-test" - 19804
} - 19805
- 19806
async fn stream( - 19807
&self, - 19808
_request: ChatRequest, - 19809
_cancel: CancellationToken, - 19810
) -> Result<EventStream, LlmError> { - 19811
let reply = self - 19812
.replies - 19813
.lock() - 19814
.ok() - 19815
.and_then(|mut replies| replies.pop_front()); - 19816
let (mut sink, rx) = stream::channel(64); - 19817
match reply { - 19818
Some(message) => { - 19819
sink.push(stream::StreamEvent::Start { - 19820
partial: message.clone(), - 19821
}); - 19822
sink.close_message(message).await; - 19823
} - 19824
None => { - 19825
sink.close_error(LlmError::Parse("revision test replies exhausted".into())) - 19826
.await - 19827
} - 19828
} - 19829
Ok(rx) - 19830
} - 19831
} - 19832
- 19833
fn revision_message(content: Vec<ContentBlock>, stop_reason: StopReason) -> AssistantMessage { - 19834
AssistantMessage { - 19835
content, - 19836
stop_reason, - 19837
usage: Usage::default(), - 19838
model: "test-model".into(), - 19839
response_id: None, - 19840
} - 19841
} - 19842
- 19843
fn seed_bound_result( - 19844
core: &Core, - 19845
session_id: &str, - 19846
execution_id: &str, - 19847
) -> vak_session::SessionLog { - 19848
let path = core - 19849
.sessions_home() - 19850
.join("sessions") - 19851
.join(vak_core::memory::hash_cwd(core.cwd())) - 19852
.join(format!("{session_id}.jsonl")); - 19853
let mut log = vak_session::SessionLog::create( - 19854
path, - 19855
vak_session::types::SessionHeader { - 19856
agent: Some(vak_core::vak_agent_identity()), - 19857
session_id: session_id.into(), - 19858
created_at: chrono::Utc::now(), - 19859
cwd: core.cwd().to_path_buf(), - 19860
parent_session_id: None, - 19861
contract_id: None, - 19862
work_item_id: None, - 19863
conversation: Some(vak_session::types::ConversationContext::local( - 19864
session_id, "test", - 19865
)), - 19866
contract: vak_session::types::FrozenContract { - 19867
app_version: "test".into(), - 19868
provider: "test".into(), - 19869
model: "test".into(), - 19870
route_ladder: Vec::new(), - 19871
route_objective: String::new(), - 19872
route_annotations: Vec::new(), - 19873
system_prompt: String::new(), - 19874
permission_mode: "workspace-write".into(), - 19875
capabilities: Vec::new(), - 19876
prompt_layers: Vec::new(), - 19877
}, - 19878
}, - 19879
) - 19880
.unwrap(); - 19881
log.append_message(vak_session::types::MessageRecord { - 19882
message: vak_llm::Message::user_text("Create the result"), - 19883
meta: None, - 19884
}) - 19885
.unwrap(); - 19886
log.append_message(vak_session::types::MessageRecord { - 19887
message: vak_llm::Message::assistant(vec![ - 19888
vak_llm::ContentBlock::ToolUse { - 19889
id: execution_id.into(), - 19890
name: "bash".into(), - 19891
input: serde_json::json!({"command": "create result"}), - 19892
}, - 19893
vak_llm::ContentBlock::text("The result is ready."), - 19894
]), - 19895
meta: None, - 19896
}) - 19897
.unwrap(); - 19898
log - 19899
} - 19900
- 19901
/// Target verification runs in the broker worker, so a test that - 19902
/// freezes or promotes a candidate needs the real worker binary. - 19903
fn pin_test_tool_worker(core: &Core) { - 19904
let worker = std::env::current_exe() - 19905
.unwrap() - 19906
.parent() - 19907
.unwrap() - 19908
.parent() - 19909
.unwrap() - 19910
.join("vak-tool-worker"); - 19911
assert!( - 19912
worker.is_file(), - 19913
"build vak-tool-worker (cargo build -p vak-server --bins) to run candidate verification" - 19914
); - 19915
core.set_tool_worker_exe(worker); - 19916
} - 19917
- 19918
async fn export_candidate(state: &AppState) -> vak_sandbox::CandidateRecord { - 19919
pin_test_tool_worker(&state.core); - 19920
append_session_sandbox_event( - 19921
&state.core.sessions_home(), - 19922
"session-1", - 19923
&AgentEvent::Sandbox(vak_tools::SandboxEvent::ExecutionStarted { - 19924
execution_id: "exec-1".into(), - 19925
owner_session_id: Some("session-1".into()), - 19926
tool: "bash".into(), - 19927
code_preview: "create result".into(), - 19928
language: "bash".into(), - 19929
scratch_dir: ".vak/scratch/e1".into(), - 19930
}), - 19931
); - 19932
let response = export_sandbox_candidate( - 19933
State(state.clone()), - 19934
Path("session-1".into()), - 19935
Json(SandboxCandidateBody { - 19936
execution_id: "exec-1".into(), - 19937
source: ".vak/scratch/e1".into(), - 19938
destination: ".".into(), - 19939
}), - 19940
) - 19941
.await; - 19942
assert_eq!(response.status(), StatusCode::OK); - 19943
let record: vak_sandbox::DurableRecord = serde_json::from_slice( - 19944
&axum::body::to_bytes(response.into_body(), 64 * 1024) - 19945
.await - 19946
.unwrap(), - 19947
) - 19948
.unwrap(); - 19949
let vak_sandbox::DurableRecord::Candidate(record) = record else { - 19950
panic!("candidate response") - 19951
}; - 19952
record - 19953
} - 19954
- 19955
/// A ledger in which the Agent made `calls` (`office_apply` id and - 19956
/// arguments), each succeeding, then answered. - 19957
fn seed_office_calls(core: &Core, session_id: &str, calls: &[(&str, serde_json::Value)]) { - 19958
let mut log = seed_bound_result(core, session_id, "unused"); - 19959
log.append_message(vak_session::types::MessageRecord { - 19960
message: vak_llm::Message::user_text("Update the budget"), - 19961
meta: None, - 19962
}) - 19963
.unwrap(); - 19964
for (id, input) in calls { - 19965
log.append_message(vak_session::types::MessageRecord { - 19966
message: vak_llm::Message::assistant(vec![vak_llm::ContentBlock::ToolUse { - 19967
id: (*id).into(), - 19968
name: "office_apply".into(), - 19969
input: input.clone(), - 19970
}]), - 19971
meta: None, - 19972
}) - 19973
.unwrap(); - 19974
log.append_message(vak_session::types::MessageRecord { - 19975
message: vak_llm::Message { - 19976
role: vak_llm::Role::User, - 19977
content: vec![vak_llm::ContentBlock::ToolResult { - 19978
tool_use_id: (*id).into(), - 19979
content: "Draft written.".into(), - 19980
is_error: false, - 19981
}], - 19982
}, - 19983
meta: None, - 19984
}) - 19985
.unwrap(); - 19986
} - 19987
log.append_message(vak_session::types::MessageRecord { - 19988
message: vak_llm::Message::assistant(vec![vak_llm::ContentBlock::text( - 19989
"The draft is ready for review.", - 19990
)]), - 19991
meta: None, - 19992
}) - 19993
.unwrap(); - 19994
} - 19995
- 19996
async fn body_json(response: axum::response::Response) -> serde_json::Value { - 19997
serde_json::from_slice( - 19998
&axum::body::to_bytes(response.into_body(), 1024 * 1024) - 19999
.await - 20000
.unwrap(), - 20001
) - 20002
.unwrap() - 20003
} - 20004
- 20005
fn sha256_prefix(bytes: &[u8]) -> String { - 20006
use sha2::Digest as _; - 20007
sha2::Sha256::digest(bytes) - 20008
.iter() - 20009
.take(8) - 20010
.map(|byte| format!("{byte:02x}")) - 20011
.collect() - 20012
} - 20013
- 20014
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 20015
async fn an_office_draft_is_reviewed_by_meaning_narrowed_and_accepted_through_promotion() { - 20016
vak_config::paths::isolate_home_for_tests(); - 20017
let dir = tempfile::tempdir().unwrap(); - 20018
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 20019
core.set_sessions_home(dir.path().join("home")); - 20020
let workbook = dir.path().join("budget.xlsx"); - 20021
tokio::fs::write(&workbook, vak_ooxml::fixtures::xlsx()) - 20022
.await - 20023
.unwrap(); - 20024
let first_draft = ".vak/scratch/vak/exec-1/budget.xlsx"; - 20025
let first = serde_json::json!({ - 20026
"path": "budget.xlsx", - 20027
"base_digest": sha256_prefix(&vak_ooxml::fixtures::xlsx()), - 20028
"ops": [{"op": "set_cells", "sheet": "Budget", "cells": {"B2": 150}}] - 20029
}); - 20030
let state = AppState::new(core); - 20031
pin_test_tool_worker(&state.core); - 20032
let tool = vak_tools::brokered_default_tools(state.core.tool_worker_exe()) - 20033
.into_iter() - 20034
.find(|tool| tool.name() == "office_apply") - 20035
.unwrap(); - 20036
let run = |id: &'static str, args: serde_json::Value| { - 20037
let tool = &tool; - 20038
let cwd = dir.path().to_path_buf(); - 20039
async move { - 20040
let (sink, _events) = vak_tools::SandboxEventSink::new_with_id(id.into()); - 20041
tool.execute( - 20042
&args, - 20043
&vak_tools::ToolContext::new(cwd).with_sandbox_sink(sink), - 20044
) - 20045
.await - 20046
} - 20047
}; - 20048
let output = run("exec-1", first.clone()).await; - 20049
assert!(!output.is_error, "{}", output.content); - 20050
// The Agent keeps editing its own draft: a second call, whose source - 20051
// is the first draft, so the lineage spans both calls. - 20052
let second = serde_json::json!({ - 20053
"path": "budget.xlsx", - 20054
"source": first_draft, - 20055
"base_digest": sha256_prefix(&tokio::fs::read(dir.path().join(first_draft)).await.unwrap()), - 20056
"ops": [{"op": "set_cells", "sheet": "Budget", "cells": {"B3": 70}}] - 20057
}); - 20058
let output = run("exec-2", second.clone()).await; - 20059
assert!(!output.is_error, "{}", output.content); - 20060
assert_eq!( - 20061
tokio::fs::read(&workbook).await.unwrap(), - 20062
vak_ooxml::fixtures::xlsx(), - 20063
"the workspace file is untouched until review" - 20064
); - 20065
seed_office_calls( - 20066
&state.core, - 20067
"session-1", - 20068
&[("exec-1", first), ("exec-2", second)], - 20069
); - 20070
- 20071
append_session_sandbox_event( - 20072
&state.core.sessions_home(), - 20073
"session-1", - 20074
&AgentEvent::Sandbox(vak_tools::SandboxEvent::ExecutionStarted { - 20075
execution_id: "exec-2".into(), - 20076
owner_session_id: Some("session-1".into()), - 20077
tool: "office_apply".into(), - 20078
code_preview: "{}".into(), - 20079
language: "json".into(), - 20080
scratch_dir: ".vak/scratch/vak/exec-2".into(), - 20081
}), - 20082
); - 20083
let response = export_sandbox_candidate( - 20084
State(state.clone()), - 20085
Path("session-1".into()), - 20086
Json(SandboxCandidateBody { - 20087
execution_id: "exec-2".into(), - 20088
source: ".vak/scratch/vak/exec-2".into(), - 20089
destination: ".".into(), - 20090
}), - 20091
) - 20092
.await; - 20093
assert_eq!(response.status(), StatusCode::OK); - 20094
let record: vak_sandbox::DurableRecord = - 20095
serde_json::from_value(body_json(response).await).unwrap(); - 20096
let vak_sandbox::DurableRecord::Candidate(candidate) = record else { - 20097
panic!("candidate response") - 20098
}; - 20099
assert_eq!(candidate.draft_checks.len(), 1); - 20100
assert_eq!(candidate.draft_checks[0].verifier, "format.openxml"); - 20101
assert_eq!( - 20102
candidate.draft_checks[0].status, "passed", - 20103
"{}", - 20104
candidate.draft_checks[0].evidence - 20105
); - 20106
let candidate_id = candidate.candidate.candidate_id.clone(); - 20107
let review = |id: String| { - 20108
let state = state.clone(); - 20109
async move { - 20110
read_sandbox_candidate_office_review( - 20111
State(state), - 20112
Path(("session-1".into(), id)), - 20113
axum::extract::Query(FileQuery { - 20114
path: "budget.xlsx".into(), - 20115
}), - 20116
) - 20117
.await - 20118
} - 20119
}; - 20120
- 20121
let response = review(candidate_id.clone()).await; - 20122
assert_eq!(response.status(), StatusCode::OK); - 20123
let diff = body_json(response).await; - 20124
assert_eq!(diff["summary"], serde_json::json!(["Budget: 2 changed"])); - 20125
assert_eq!(diff["changes"][0]["anchor"], "Budget!B2"); - 20126
assert_eq!(diff["changes"][0]["before"], "100"); - 20127
assert_eq!(diff["changes"][0]["after"], "150"); - 20128
assert_eq!(diff["compared_with"], "workspace"); - 20129
let impacts = diff["impact"].as_array().cloned().unwrap_or_default(); - 20130
assert!( - 20131
impacts - 20132
.iter() - 20133
.all(|impact| impact["kind"] == "recalculation"), - 20134
"unsigned and unlabelled: {diff}" - 20135
); - 20136
assert!( - 20137
impacts.iter().any(|impact| impact["message"] - 20138
.as_str() - 20139
.is_some_and(|message| message.contains("Budget!B4"))), - 20140
"the formula left showing its old value is named: {diff}" - 20141
); - 20142
assert!(diff.get("choices_unavailable").is_none(), "{diff}"); - 20143
let ids: Vec<&str> = diff["choices"] - 20144
.as_array() - 20145
.unwrap() - 20146
.iter() - 20147
.map(|choice| choice["id"].as_str().unwrap()) - 20148
.collect(); - 20149
assert_eq!(ids, ["0", "1"], "one choice per call's op, oldest first"); - 20150
assert_eq!(diff["choices"][1]["label"], "Set Budget!B3"); - 20151
assert_eq!(diff["choices"][1]["changes"][0]["after"], "70"); - 20152
- 20153
let narrow = |id: String, keep: Vec<&'static str>| { - 20154
let state = state.clone(); - 20155
async move { - 20156
narrow_sandbox_candidate_office( - 20157
State(state), - 20158
Path(("session-1".into(), id)), - 20159
Json(OfficeNarrowBody { - 20160
path: "budget.xlsx".into(), - 20161
keep: keep.into_iter().map(str::to_string).collect(), - 20162
}), - 20163
) - 20164
.await - 20165
} - 20166
}; - 20167
let response = narrow(candidate_id.clone(), vec!["1"]).await; - 20168
assert_eq!(response.status(), StatusCode::OK); - 20169
let record: vak_sandbox::DurableRecord = - 20170
serde_json::from_value(body_json(response).await).unwrap(); - 20171
let vak_sandbox::DurableRecord::Candidate(narrowed) = record else { - 20172
panic!("candidate response") - 20173
}; - 20174
assert_eq!( - 20175
narrowed.parent_candidate_id.as_deref(), - 20176
Some(candidate_id.as_str()) - 20177
); - 20178
assert_eq!(narrowed.draft_checks[0].status, "passed"); - 20179
let narrowed_id = narrowed.candidate.candidate_id.clone(); - 20180
- 20181
let diff = body_json(review(narrowed_id.clone()).await).await; - 20182
assert_eq!(diff["summary"], serde_json::json!(["Budget: 1 changed"])); - 20183
assert_eq!(diff["changes"][0]["anchor"], "Budget!B3"); - 20184
assert_eq!(diff["narrowed_from"]["candidate_id"], candidate_id.as_str()); - 20185
assert!( - 20186
diff["choices_unavailable"] - 20187
.as_str() - 20188
.unwrap() - 20189
.contains("keeps 1 of the draft's changes") - 20190
); - 20191
- 20192
// A comment on an Office draft points at a cell, not a line. - 20193
let ledger = find_session_on_disk(&state.core, "session-1").unwrap(); - 20194
register_handle( - 20195
&state, - 20196
"session-1".into(), - 20197
ledger, - 20198
state.core.cwd().to_path_buf(), - 20199
state.core.clone(), - 20200
); - 20201
let comment = |anchor: Option<&str>, line: Option<u32>| { - 20202
let state = state.clone(); - 20203
let candidate_id = candidate_id.clone(); - 20204
let anchor = anchor.map(str::to_string); - 20205
async move { - 20206
comment_on_sandbox_candidate( - 20207
State(state), - 20208
Path(("session-1".into(), candidate_id)), - 20209
axum::Extension(AuthenticatedPrincipal::Operator), - 20210
Json(CandidateCommentBody { - 20211
text: "Keep the old figure here".into(), - 20212
path: Some("budget.xlsx".into()), - 20213
line_start: line, - 20214
line_end: None, - 20215
anchor, - 20216
request_id: None, - 20217
}), - 20218
) - 20219
.await - 20220
.status() - 20221
} - 20222
}; - 20223
assert_eq!(comment(Some("Budget!B2"), None).await, StatusCode::CREATED); - 20224
assert_eq!( - 20225
comment(Some("Budget B2"), None).await, - 20226
StatusCode::BAD_REQUEST - 20227
); - 20228
assert_eq!( - 20229
comment(None, Some(4)).await, - 20230
StatusCode::BAD_REQUEST, - 20231
"line numbers mean nothing in a package" - 20232
); - 20233
let listed = body_json( - 20234
list_sandbox_candidate_comments( - 20235
State(state.clone()), - 20236
Path(("session-1".into(), candidate_id.clone())), - 20237
) - 20238
.await, - 20239
) - 20240
.await; - 20241
let anchored: Vec<&serde_json::Value> = listed["comments"] - 20242
.as_array() - 20243
.unwrap() - 20244
.iter() - 20245
.filter(|comment| comment["anchor"] == "Budget!B2") - 20246
.collect(); - 20247
assert_eq!(anchored.len(), 1, "{listed}"); - 20248
assert_eq!(anchored[0]["path"], "budget.xlsx"); - 20249
- 20250
let response = read_sandbox_candidate_office_projection( - 20251
State(state.clone()), - 20252
Path(("session-1".into(), narrowed_id.clone())), - 20253
axum::extract::Query(OfficeProjectionQuery { - 20254
path: "budget.xlsx".into(), - 20255
from: 0, - 20256
view: None, - 20257
at: None, - 20258
}), - 20259
) - 20260
.await; - 20261
assert_eq!(response.status(), StatusCode::OK); - 20262
let page = body_json(response).await; - 20263
assert!( - 20264
page["units"] - 20265
.as_array() - 20266
.unwrap() - 20267
.iter() - 20268
.any(|unit| unit["cells"].as_array().is_some_and(|cells| cells - 20269
.iter() - 20270
.any(|cell| cell[0] == "B3" && cell[1] == "70"))), - 20271
"the saved version's own cells: {page}" - 20272
); - 20273
- 20274
let response = narrow(narrowed_id.clone(), vec!["1"]).await; - 20275
assert_eq!(response.status(), StatusCode::CONFLICT); - 20276
let response = narrow(candidate_id.clone(), vec![]).await; - 20277
assert_eq!(response.status(), StatusCode::BAD_REQUEST); - 20278
let response = narrow(candidate_id.clone(), vec!["7"]).await; - 20279
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - 20280
- 20281
let response = promote_sandbox_candidate( - 20282
State(state.clone()), - 20283
Path("session-1".into()), - 20284
Json(SandboxPromotionBody { - 20285
candidate_id: narrowed_id, - 20286
files: vec!["budget.xlsx".into()], - 20287
}), - 20288
) - 20289
.await; - 20290
assert_eq!(response.status(), StatusCode::OK); - 20291
let promoted = tokio::fs::read(&workbook).await.unwrap(); - 20292
let text = - 20293
vak_ooxml::read::read(std::io::Cursor::new(promoted), vak_ooxml::Limits::default()) - 20294
.unwrap() - 20295
.lines() - 20296
.join("\n"); - 20297
assert!(text.contains("B3: 70"), "{text}"); - 20298
assert!( - 20299
text.contains("B2: 100"), - 20300
"the left-out change is not applied: {text}" - 20301
); - 20302
} - 20303
- 20304
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 20305
async fn a_document_created_from_scratch_is_reviewed_narrowed_and_accepted() { - 20306
vak_config::paths::isolate_home_for_tests(); - 20307
let dir = tempfile::tempdir().unwrap(); - 20308
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 20309
core.set_sessions_home(dir.path().join("home")); - 20310
let state = AppState::new(core); - 20311
pin_test_tool_worker(&state.core); - 20312
let tool = vak_tools::brokered_default_tools(state.core.tool_worker_exe()) - 20313
.into_iter() - 20314
.find(|tool| tool.name() == "office_apply") - 20315
.unwrap(); - 20316
let args = serde_json::json!({ - 20317
"path": "memo.docx", - 20318
"ops": [ - 20319
{"op": "add_paragraph", "text": "Q3 review", "style": "Title"}, - 20320
{"op": "add_paragraph", "text": "Revenue grew 12%."}, - 20321
{"op": "add_paragraph", "text": "Costs held flat."} - 20322
] - 20323
}); - 20324
let (sink, _events) = vak_tools::SandboxEventSink::new_with_id("exec-1".into()); - 20325
let output = tool - 20326
.execute( - 20327
&args, - 20328
&vak_tools::ToolContext::new(dir.path().to_path_buf()).with_sandbox_sink(sink), - 20329
) - 20330
.await; - 20331
assert!(!output.is_error, "{}", output.content); - 20332
let memo = dir.path().join("memo.docx"); - 20333
assert!( - 20334
!memo.exists(), - 20335
"nothing reaches the workspace before review" - 20336
); - 20337
seed_office_calls(&state.core, "session-1", &[("exec-1", args)]); - 20338
append_session_sandbox_event( - 20339
&state.core.sessions_home(), - 20340
"session-1", - 20341
&AgentEvent::Sandbox(vak_tools::SandboxEvent::ExecutionStarted { - 20342
execution_id: "exec-1".into(), - 20343
owner_session_id: Some("session-1".into()), - 20344
tool: "office_apply".into(), - 20345
code_preview: "{}".into(), - 20346
language: "json".into(), - 20347
scratch_dir: ".vak/scratch/vak/exec-1".into(), - 20348
}), - 20349
); - 20350
let response = export_sandbox_candidate( - 20351
State(state.clone()), - 20352
Path("session-1".into()), - 20353
Json(SandboxCandidateBody { - 20354
execution_id: "exec-1".into(), - 20355
source: ".vak/scratch/vak/exec-1".into(), - 20356
destination: ".".into(), - 20357
}), - 20358
) - 20359
.await; - 20360
assert_eq!(response.status(), StatusCode::OK); - 20361
let record: vak_sandbox::DurableRecord = - 20362
serde_json::from_value(body_json(response).await).unwrap(); - 20363
let vak_sandbox::DurableRecord::Candidate(candidate) = record else { - 20364
panic!("candidate response") - 20365
}; - 20366
assert_eq!( - 20367
candidate.draft_checks[0].status, "passed", - 20368
"{}", - 20369
candidate.draft_checks[0].evidence - 20370
); - 20371
let candidate_id = candidate.candidate.candidate_id.clone(); - 20372
- 20373
let response = read_sandbox_candidate_office_review( - 20374
State(state.clone()), - 20375
Path(("session-1".into(), candidate_id.clone())), - 20376
axum::extract::Query(FileQuery { - 20377
path: "memo.docx".into(), - 20378
}), - 20379
) - 20380
.await; - 20381
assert_eq!(response.status(), StatusCode::OK); - 20382
let diff = body_json(response).await; - 20383
assert_eq!(diff["compared_with"], "nothing (new file)", "{diff}"); - 20384
assert!( - 20385
diff["changes"][0]["after"] - 20386
.as_str() - 20387
.is_some_and(|after| after.starts_with("new file: 3 paragraphs")), - 20388
"{diff}" - 20389
); - 20390
assert!(diff.get("choices_unavailable").is_none(), "{diff}"); - 20391
let labels: Vec<&str> = diff["choices"] - 20392
.as_array() - 20393
.unwrap() - 20394
.iter() - 20395
.map(|choice| choice["label"].as_str().unwrap()) - 20396
.collect(); - 20397
assert_eq!( - 20398
labels, - 20399
[ - 20400
"New paragraph: Q3 review", - 20401
"New paragraph: Revenue grew 12%.", - 20402
"New paragraph: Costs held flat." - 20403
], - 20404
"a from-scratch draft replays from the blank, one choice per op" - 20405
); - 20406
- 20407
let response = narrow_sandbox_candidate_office( - 20408
State(state.clone()), - 20409
Path(("session-1".into(), candidate_id.clone())), - 20410
Json(OfficeNarrowBody { - 20411
path: "memo.docx".into(), - 20412
keep: vec!["0".into(), "2".into()], - 20413
}), - 20414
) - 20415
.await; - 20416
assert_eq!(response.status(), StatusCode::OK); - 20417
let record: vak_sandbox::DurableRecord = - 20418
serde_json::from_value(body_json(response).await).unwrap(); - 20419
let vak_sandbox::DurableRecord::Candidate(narrowed) = record else { - 20420
panic!("candidate response") - 20421
}; - 20422
assert_eq!(narrowed.draft_checks[0].status, "passed"); - 20423
let response = promote_sandbox_candidate( - 20424
State(state.clone()), - 20425
Path("session-1".into()), - 20426
Json(SandboxPromotionBody { - 20427
candidate_id: narrowed.candidate.candidate_id.clone(), - 20428
files: vec!["memo.docx".into()], - 20429
}), - 20430
) - 20431
.await; - 20432
assert_eq!(response.status(), StatusCode::OK); - 20433
let document = vak_ooxml::read::read( - 20434
std::io::Cursor::new(tokio::fs::read(&memo).await.unwrap()), - 20435
vak_ooxml::Limits::default(), - 20436
) - 20437
.unwrap(); - 20438
let text = document.lines().join("\n"); - 20439
assert!(text.contains("# Q3 review"), "{text}"); - 20440
assert!(text.contains("Costs held flat."), "{text}"); - 20441
assert!( - 20442
!text.contains("Revenue grew"), - 20443
"the left-out paragraph is not in the accepted file: {text}" - 20444
); - 20445
assert!( - 20446
!text.contains("[inserted by"), - 20447
"a new document is clean: {text}" - 20448
); - 20449
} - 20450
- 20451
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 20452
async fn the_canvas_reads_an_office_file_as_pages_and_structure_through_the_worker() { - 20453
vak_config::paths::isolate_home_for_tests(); - 20454
let dir = tempfile::tempdir().unwrap(); - 20455
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 20456
core.set_sessions_home(dir.path().join("home")); - 20457
let state = AppState::new(core); - 20458
pin_test_tool_worker(&state.core); - 20459
tokio::fs::write(dir.path().join("q3.docx"), vak_ooxml::fixtures::docx()) - 20460
.await - 20461
.unwrap(); - 20462
tokio::fs::write(dir.path().join("notes.txt"), "plain") - 20463
.await
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.