- 20464
.unwrap(); - 20465
let read = |path: &str, from: usize, view: Option<&str>| { - 20466
let state = state.clone(); - 20467
let query = OfficeProjectionQuery { - 20468
path: path.into(), - 20469
from, - 20470
view: view.map(str::to_string), - 20471
at: None, - 20472
}; - 20473
async move { read_office_projection(State(state), axum::extract::Query(query)).await } - 20474
}; - 20475
- 20476
let response = read("q3.docx", 0, None).await; - 20477
assert_eq!(response.status(), StatusCode::OK); - 20478
let page = body_json(response).await; - 20479
assert_eq!(page["kind"], "Word document"); - 20480
assert_eq!(page["path"], "q3.docx"); - 20481
assert_eq!(page["sha256"].as_str().unwrap().len(), 64); - 20482
assert!(page["units"].as_array().unwrap().len() > 1); - 20483
assert_eq!(page["from"], 0); - 20484
assert!( - 20485
page["outline"] - 20486
.as_array() - 20487
.is_some_and(|outline| !outline.is_empty()) - 20488
); - 20489
- 20490
let response = read("q3.docx", 0, Some("structure")).await; - 20491
assert_eq!(response.status(), StatusCode::OK); - 20492
let structure = body_json(response).await; - 20493
assert_eq!(structure["main_part"], "word/document.xml"); - 20494
assert!(structure["parts"].as_array().unwrap().len() > 2); - 20495
- 20496
let cited = page["units"][2]["anchor"].as_str().unwrap().to_string(); - 20497
let response = read_office_projection( - 20498
State(state.clone()), - 20499
axum::extract::Query(OfficeProjectionQuery { - 20500
path: "q3.docx".into(), - 20501
from: 0, - 20502
view: None, - 20503
at: Some(cited.clone()), - 20504
}), - 20505
) - 20506
.await; - 20507
let at = body_json(response).await; - 20508
assert_eq!(at["from"], 1, "the page opens at the cited section: {at}"); - 20509
assert_eq!(at["focus"], cited.as_str()); - 20510
assert_eq!(at["units"][0]["text"], "Summary", "{at}"); - 20511
assert_eq!(at["units"][1]["anchor"], cited.as_str()); - 20512
- 20513
let response = read("q3.docx", 0, Some("facts")).await; - 20514
assert_eq!(response.status(), StatusCode::OK); - 20515
let facts = body_json(response).await; - 20516
assert_eq!(facts["kind"], "Word document"); - 20517
assert!(facts["units"].as_array().unwrap().is_empty(), "{facts}"); - 20518
assert_eq!(facts["total_units"], page["total_units"]); - 20519
assert!( - 20520
facts["stats"] - 20521
.as_array() - 20522
.is_some_and(|stats| !stats.is_empty()) - 20523
); - 20524
- 20525
assert_eq!( - 20526
read("notes.txt", 0, None).await.status(), - 20527
StatusCode::BAD_REQUEST - 20528
); - 20529
assert_eq!( - 20530
read("missing.docx", 0, None).await.status(), - 20531
StatusCode::NOT_FOUND - 20532
); - 20533
assert_eq!( - 20534
read("../outside.docx", 0, None).await.status(), - 20535
StatusCode::FORBIDDEN - 20536
); - 20537
} - 20538
- 20539
#[test] - 20540
fn a_revision_request_names_the_cell_or_lines_a_comment_points_at() { - 20541
let data = |pairs: &[(&str, &str)]| { - 20542
pairs - 20543
.iter() - 20544
.map(|(key, value)| (key.to_string(), value.to_string())) - 20545
.collect::<std::collections::BTreeMap<_, _>>() - 20546
}; - 20547
assert_eq!( - 20548
comment_location(&data(&[ - 20549
("path", "budget.xlsx"), - 20550
("anchor", "'Q4 plan'!B4") - 20551
])), - 20552
" file budget.xlsx, at 'Q4 plan'!B4" - 20553
); - 20554
assert_eq!( - 20555
comment_location(&data(&[ - 20556
("path", "a.txt"), - 20557
("line_start", "3"), - 20558
("line_end", "5") - 20559
])), - 20560
" file a.txt, lines 3-5" - 20561
); - 20562
assert_eq!( - 20563
comment_location(&data(&[("path", "a.txt"), ("line_start", "3")])), - 20564
" file a.txt, line 3" - 20565
); - 20566
assert_eq!(comment_location(&data(&[("path", "a.txt")])), " file a.txt"); - 20567
assert_eq!(comment_location(&data(&[])), ""); - 20568
} - 20569
- 20570
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 20571
async fn a_draft_changed_after_office_apply_is_offered_only_whole() { - 20572
vak_config::paths::isolate_home_for_tests(); - 20573
let dir = tempfile::tempdir().unwrap(); - 20574
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 20575
core.set_sessions_home(dir.path().join("home")); - 20576
let state = AppState::new(core); - 20577
pin_test_tool_worker(&state.core); - 20578
tokio::fs::write(dir.path().join("budget.xlsx"), vak_ooxml::fixtures::xlsx()) - 20579
.await - 20580
.unwrap(); - 20581
let args = serde_json::json!({ - 20582
"path": "budget.xlsx", - 20583
"base_digest": sha256_prefix(&vak_ooxml::fixtures::xlsx()), - 20584
"ops": [{"op": "set_cells", "sheet": "Budget", "cells": {"B2": 150, "B3": 70}}] - 20585
}); - 20586
seed_office_calls(&state.core, "session-1", &[("exec-1", args)]); - 20587
// A draft that no office_apply call produced: a command wrote it. - 20588
let scratch = dir.path().join(".vak/scratch/vak/exec-1"); - 20589
tokio::fs::create_dir_all(&scratch).await.unwrap(); - 20590
let other = vak_ooxml::edit::apply( - 20591
&vak_ooxml::fixtures::xlsx(), - 20592
&[vak_ooxml::edit::OfficeOp::SetCells { - 20593
sheet: "Budget".into(), - 20594
cells: std::collections::BTreeMap::from([( - 20595
"B2".to_string(), - 20596
vak_ooxml::edit::CellValue::Number(999.0), - 20597
)]), - 20598
}], - 20599
&vak_ooxml::edit::EditContext { - 20600
author: "Vakyartha".into(), - 20601
date: "2026-09-24T10:00:00Z".into(), - 20602
tracked: true, - 20603
}, - 20604
vak_ooxml::Limits::default(), - 20605
None, - 20606
) - 20607
.unwrap(); - 20608
tokio::fs::write(scratch.join("budget.xlsx"), &other.bytes) - 20609
.await - 20610
.unwrap(); - 20611
append_session_sandbox_event( - 20612
&state.core.sessions_home(), - 20613
"session-1", - 20614
&AgentEvent::Sandbox(vak_tools::SandboxEvent::ExecutionStarted { - 20615
execution_id: "exec-1".into(), - 20616
owner_session_id: Some("session-1".into()), - 20617
tool: "office_apply".into(), - 20618
code_preview: "{}".into(), - 20619
language: "json".into(), - 20620
scratch_dir: ".vak/scratch/vak/exec-1".into(), - 20621
}), - 20622
); - 20623
let response = export_sandbox_candidate( - 20624
State(state.clone()), - 20625
Path("session-1".into()), - 20626
Json(SandboxCandidateBody { - 20627
execution_id: "exec-1".into(), - 20628
source: ".vak/scratch/vak/exec-1".into(), - 20629
destination: ".".into(), - 20630
}), - 20631
) - 20632
.await; - 20633
let record: vak_sandbox::DurableRecord = - 20634
serde_json::from_value(body_json(response).await).unwrap(); - 20635
let vak_sandbox::DurableRecord::Candidate(candidate) = record else { - 20636
panic!("candidate response") - 20637
}; - 20638
let response = read_sandbox_candidate_office_review( - 20639
State(state.clone()), - 20640
Path(("session-1".into(), candidate.candidate.candidate_id.clone())), - 20641
axum::extract::Query(FileQuery { - 20642
path: "budget.xlsx".into(), - 20643
}), - 20644
) - 20645
.await; - 20646
let diff = body_json(response).await; - 20647
assert!(diff.get("choices").is_none(), "{diff}"); - 20648
assert!( - 20649
diff["choices_unavailable"] - 20650
.as_str() - 20651
.unwrap() - 20652
.contains("not what its recorded edits produce"), - 20653
"{diff}" - 20654
); - 20655
assert_eq!(diff["changes"][0]["after"], "999"); - 20656
let response = narrow_sandbox_candidate_office( - 20657
State(state.clone()), - 20658
Path(("session-1".into(), candidate.candidate.candidate_id)), - 20659
Json(OfficeNarrowBody { - 20660
path: "budget.xlsx".into(), - 20661
keep: vec!["0:B2".into()], - 20662
}), - 20663
) - 20664
.await; - 20665
assert_eq!( - 20666
response.status(), - 20667
StatusCode::UNPROCESSABLE_ENTITY, - 20668
"narrowing replays and would silently drop the hand edit, so the worker's integrity check must refuse it too" - 20669
); - 20670
} - 20671
- 20672
#[tokio::test] - 20673
async fn candidate_export_and_promotion_runs_planned_target_verifier() { - 20674
vak_config::paths::isolate_home_for_tests(); - 20675
let dir = tempfile::tempdir().unwrap(); - 20676
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 20677
core.set_sessions_home(dir.path().join("home")); - 20678
seed_bound_result(&core, "session-1", "exec-1"); - 20679
let state = AppState::new(core); - 20680
let scratch = dir.path().join(".vak/scratch/e1"); - 20681
tokio::fs::create_dir_all(&scratch).await.unwrap(); - 20682
tokio::fs::write(scratch.join("result.json"), r#"{"ready":true}"#) - 20683
.await - 20684
.unwrap(); - 20685
- 20686
let candidate = export_candidate(&state).await; - 20687
assert_eq!(candidate.session_id, "session-1"); - 20688
assert_eq!(candidate.execution_id, "exec-1"); - 20689
assert!(!candidate.result_id.is_empty()); - 20690
assert_eq!(candidate.candidate.target_checks.len(), 1); - 20691
assert_eq!(candidate.candidate.target_checks[0].verifier, "format.json"); - 20692
assert_eq!(candidate.draft_checks.len(), 1); - 20693
assert_eq!(candidate.draft_checks[0].status, "passed"); - 20694
assert_eq!(candidate.draft_checks[0].path, "result.json"); - 20695
- 20696
let candidate_id = candidate.candidate.candidate_id.clone(); - 20697
let response = promote_sandbox_candidate( - 20698
State(state.clone()), - 20699
Path("session-1".into()), - 20700
Json(SandboxPromotionBody { - 20701
candidate_id: candidate_id.clone(), - 20702
files: vec!["result.json".into()], - 20703
}), - 20704
) - 20705
.await; - 20706
assert_eq!(response.status(), StatusCode::OK); - 20707
let promoted: vak_sandbox::DurableRecord = serde_json::from_slice( - 20708
&axum::body::to_bytes(response.into_body(), 64 * 1024) - 20709
.await - 20710
.unwrap(), - 20711
) - 20712
.unwrap(); - 20713
let vak_sandbox::DurableRecord::Promotion(promoted) = promoted else { - 20714
panic!("promotion response") - 20715
}; - 20716
assert_eq!(promoted.receipt.verification.len(), 1); - 20717
assert_eq!(promoted.receipt.integration.target_checks_status, "passed"); - 20718
assert_eq!(promoted.receipt.integration.target_checks.len(), 1); - 20719
assert_eq!( - 20720
promoted.receipt.integration.target_checks[0].status, - 20721
"passed" - 20722
); - 20723
assert_eq!( - 20724
tokio::fs::read_to_string(dir.path().join("result.json")) - 20725
.await - 20726
.unwrap(), - 20727
r#"{"ready":true}"# - 20728
); - 20729
let response = undo_sandbox_promotion( - 20730
State(state.clone()), - 20731
Path(("session-1".into(), candidate_id)), - 20732
) - 20733
.await; - 20734
assert_eq!(response.status(), StatusCode::OK); - 20735
assert!(!dir.path().join("result.json").exists()); - 20736
assert_eq!( - 20737
vak_sandbox::load_records(&sandbox_records_path(&state)) - 20738
.unwrap() - 20739
.len(), - 20740
3 - 20741
); - 20742
} - 20743
- 20744
#[tokio::test] - 20745
async fn candidate_export_records_failed_html_check_without_claiming_target_success() { - 20746
vak_config::paths::isolate_home_for_tests(); - 20747
let dir = tempfile::tempdir().unwrap(); - 20748
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 20749
core.set_sessions_home(dir.path().join("home")); - 20750
seed_bound_result(&core, "session-1", "exec-1"); - 20751
let state = AppState::new(core); - 20752
let scratch = dir.path().join(".vak/scratch/e1"); - 20753
tokio::fs::create_dir_all(&scratch).await.unwrap(); - 20754
tokio::fs::write( - 20755
scratch.join("blank.html"), - 20756
"<!doctype html><html><head><title>Draft</head><body><main>Missing</main></body></html>", - 20757
) - 20758
.await - 20759
.unwrap(); - 20760
- 20761
let candidate = export_candidate(&state).await; - 20762
assert!(candidate.verified, "frozen bytes still have integrity"); - 20763
assert_eq!(candidate.candidate.target_checks.len(), 1); - 20764
assert_eq!(candidate.draft_checks.len(), 1); - 20765
assert_eq!(candidate.draft_checks[0].verifier, "format.html.body"); - 20766
assert_eq!(candidate.draft_checks[0].status, "failed"); - 20767
assert!( - 20768
candidate.draft_checks[0] - 20769
.evidence - 20770
.contains("unclosed <title>") - 20771
); - 20772
} - 20773
- 20774
#[tokio::test] - 20775
async fn promotion_rejects_files_outside_the_saved_candidate() { - 20776
vak_config::paths::isolate_home_for_tests(); - 20777
let dir = tempfile::tempdir().unwrap(); - 20778
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 20779
core.set_sessions_home(dir.path().join("home")); - 20780
seed_bound_result(&core, "session-1", "exec-1"); - 20781
let state = AppState::new(core); - 20782
let scratch = dir.path().join(".vak/scratch/e1"); - 20783
tokio::fs::create_dir_all(&scratch).await.unwrap(); - 20784
tokio::fs::write(scratch.join("result.txt"), "candidate") - 20785
.await - 20786
.unwrap(); - 20787
let candidate = export_candidate(&state).await; - 20788
let response = promote_sandbox_candidate( - 20789
State(state), - 20790
Path("session-1".into()), - 20791
Json(SandboxPromotionBody { - 20792
candidate_id: candidate.candidate.candidate_id, - 20793
files: vec!["unreviewed.txt".into()], - 20794
}), - 20795
) - 20796
.await; - 20797
assert_eq!(response.status(), StatusCode::BAD_REQUEST); - 20798
assert!(!dir.path().join("unreviewed.txt").exists()); - 20799
assert!(!dir.path().join("result.txt").exists()); - 20800
} - 20801
- 20802
#[tokio::test] - 20803
async fn candidate_launch_root_is_session_bound_and_hash_verified() { - 20804
vak_config::paths::isolate_home_for_tests(); - 20805
let dir = tempfile::tempdir().unwrap(); - 20806
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 20807
core.set_sessions_home(dir.path().join("home")); - 20808
seed_bound_result(&core, "session-1", "exec-1"); - 20809
let state = AppState::new(core); - 20810
let scratch = dir.path().join(".vak/scratch/e1"); - 20811
tokio::fs::create_dir_all(&scratch).await.unwrap(); - 20812
tokio::fs::write(scratch.join("invitation.html"), "<h1>Saved</h1>") - 20813
.await - 20814
.unwrap(); - 20815
let candidate = export_candidate(&state).await; - 20816
let candidate_id = candidate.candidate.candidate_id; - 20817
- 20818
let root = launch_root(&state, "session-1", Some(&candidate_id), dir.path()).unwrap(); - 20819
assert_eq!(root, sandbox_candidates_root(&state).join(&candidate_id)); - 20820
let launch = detect_launch(&root); - 20821
assert_eq!(launch.len(), 1); - 20822
assert_eq!(launch[0].name, "static"); - 20823
assert!(launch_root(&state, "another-session", Some(&candidate_id), dir.path()).is_err()); - 20824
- 20825
let prepared = sandbox_previews_root(&state).join(&candidate_id); - 20826
std::fs::create_dir_all(&prepared).unwrap(); - 20827
std::fs::copy( - 20828
root.join("invitation.html"), - 20829
prepared.join("invitation.html"), - 20830
) - 20831
.unwrap(); - 20832
std::fs::write( - 20833
prepared.join(".vak-candidate-digest"), - 20834
&candidate.candidate_digest, - 20835
) - 20836
.unwrap(); - 20837
assert_eq!( - 20838
launch_root(&state, "session-1", Some(&candidate_id), dir.path()).unwrap(), - 20839
prepared - 20840
); - 20841
- 20842
state - 20843
.sessions - 20844
.lock() - 20845
.unwrap_or_else(std::sync::PoisonError::into_inner) - 20846
.remove("session-1"); - 20847
let response = get_launch( - 20848
State(state), - 20849
Path("session-1".into()), - 20850
axum::extract::Query(LaunchScope { - 20851
candidate_id: Some(candidate_id), - 20852
}), - 20853
) - 20854
.await; - 20855
assert_eq!(response.0["servers"][0]["name"], "static"); - 20856
} - 20857
- 20858
#[tokio::test] - 20859
async fn promotion_uses_frozen_candidate_after_scratch_changes() { - 20860
vak_config::paths::isolate_home_for_tests(); - 20861
let dir = tempfile::tempdir().unwrap(); - 20862
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 20863
core.set_sessions_home(dir.path().join("home")); - 20864
seed_bound_result(&core, "session-1", "exec-1"); - 20865
let state = AppState::new(core); - 20866
let scratch = dir.path().join(".vak/scratch/e1"); - 20867
tokio::fs::create_dir_all(&scratch).await.unwrap(); - 20868
tokio::fs::write(scratch.join("result.txt"), "reviewed") - 20869
.await - 20870
.unwrap(); - 20871
let candidate = export_candidate(&state).await; - 20872
tokio::fs::write(scratch.join("result.txt"), "changed") - 20873
.await - 20874
.unwrap(); - 20875
let preview = read_sandbox_candidate_file( - 20876
State(state.clone()), - 20877
Path(("session-1".into(), candidate.candidate.candidate_id.clone())), - 20878
axum::extract::Query(FileQuery { - 20879
path: "result.txt".into(), - 20880
}), - 20881
) - 20882
.await; - 20883
assert_eq!(preview.status(), StatusCode::OK); - 20884
let preview: serde_json::Value = serde_json::from_slice( - 20885
&axum::body::to_bytes(preview.into_body(), 64 * 1024) - 20886
.await - 20887
.unwrap(), - 20888
) - 20889
.unwrap(); - 20890
assert_eq!(preview["content"], "reviewed"); - 20891
let raw = read_sandbox_candidate_file_raw( - 20892
State(state.clone()), - 20893
Path(("session-1".into(), candidate.candidate.candidate_id.clone())), - 20894
axum::extract::Query(FileQuery { - 20895
path: "result.txt".into(), - 20896
}), - 20897
) - 20898
.await; - 20899
assert_eq!(raw.status(), StatusCode::OK); - 20900
assert_eq!( - 20901
axum::body::to_bytes(raw.into_body(), 64 * 1024) - 20902
.await - 20903
.unwrap() - 20904
.as_ref(), - 20905
b"reviewed" - 20906
); - 20907
let wrong_session = read_sandbox_candidate_file( - 20908
State(state.clone()), - 20909
Path(( - 20910
"another-session".into(), - 20911
candidate.candidate.candidate_id.clone(), - 20912
)), - 20913
axum::extract::Query(FileQuery { - 20914
path: "result.txt".into(), - 20915
}), - 20916
) - 20917
.await; - 20918
assert_eq!(wrong_session.status(), StatusCode::NOT_FOUND); - 20919
let saved_file = candidate.candidate.source_root.join("result.txt"); - 20920
let mut permissions = std::fs::metadata(&saved_file).unwrap().permissions(); - 20921
#[cfg(unix)] - 20922
{ - 20923
use std::os::unix::fs::PermissionsExt; - 20924
permissions.set_mode(permissions.mode() | 0o200); - 20925
} - 20926
#[cfg(not(unix))] - 20927
permissions.set_readonly(false); - 20928
std::fs::set_permissions(&saved_file, permissions).unwrap(); - 20929
tokio::fs::write(&saved_file, "tampered").await.unwrap(); - 20930
let tampered = read_sandbox_candidate_file( - 20931
State(state.clone()), - 20932
Path(("session-1".into(), candidate.candidate.candidate_id.clone())), - 20933
axum::extract::Query(FileQuery { - 20934
path: "result.txt".into(), - 20935
}), - 20936
) - 20937
.await; - 20938
assert_eq!(tampered.status(), StatusCode::CONFLICT); - 20939
tokio::fs::write(&saved_file, "reviewed").await.unwrap(); - 20940
let invalid_comment = comment_on_sandbox_candidate( - 20941
State(state.clone()), - 20942
Path(("session-1".into(), candidate.candidate.candidate_id.clone())), - 20943
axum::Extension(AuthenticatedPrincipal::Operator), - 20944
Json(CandidateCommentBody { - 20945
text: "Change this".into(), - 20946
path: Some("not-reviewed.txt".into()), - 20947
line_start: None, - 20948
line_end: None, - 20949
anchor: None, - 20950
request_id: Some("invalid-comment".into()), - 20951
}), - 20952
) - 20953
.await; - 20954
assert_eq!(invalid_comment.status(), StatusCode::BAD_REQUEST); - 20955
let invalid_range = comment_on_sandbox_candidate( - 20956
State(state.clone()), - 20957
Path(("session-1".into(), candidate.candidate.candidate_id.clone())), - 20958
axum::Extension(AuthenticatedPrincipal::Operator), - 20959
Json(CandidateCommentBody { - 20960
text: "Change this line".into(), - 20961
path: Some("result.txt".into()), - 20962
line_start: None, - 20963
line_end: Some(2), - 20964
anchor: None, - 20965
request_id: Some("invalid-range".into()), - 20966
}), - 20967
) - 20968
.await; - 20969
assert_eq!(invalid_range.status(), StatusCode::BAD_REQUEST); - 20970
let anchor_on_text = comment_on_sandbox_candidate( - 20971
State(state.clone()), - 20972
Path(("session-1".into(), candidate.candidate.candidate_id.clone())), - 20973
axum::Extension(AuthenticatedPrincipal::Operator), - 20974
Json(CandidateCommentBody { - 20975
text: "Change this cell".into(), - 20976
path: Some("result.txt".into()), - 20977
line_start: None, - 20978
line_end: None, - 20979
anchor: Some("Budget!B2".into()), - 20980
request_id: Some("anchor-on-text".into()), - 20981
}), - 20982
) - 20983
.await; - 20984
assert_eq!( - 20985
anchor_on_text.status(), - 20986
StatusCode::BAD_REQUEST, - 20987
"a text file is commented on by line" - 20988
); - 20989
let ledger_path = find_session_on_disk(&state.core, "session-1") - 20990
.unwrap() - 20991
.path() - 20992
.to_path_buf(); - 20993
let mut ledger = vak_session::SessionLog::open(ledger_path).unwrap(); - 20994
ledger - 20995
.append_activity(vak_session::ActivityRecord { - 20996
activity_id: "comment-history-1".into(), - 20997
turn: None, - 20998
kind: vak_session::ActivityKind::CandidateComment, - 20999
status: vak_session::ActivityStatus::Succeeded, - 21000
label: "Candidate comment".into(), - 21001
detail: None, - 21002
data: std::collections::BTreeMap::from([ - 21003
("actor_id".into(), "operator".into()), - 21004
( - 21005
"candidate_id".into(), - 21006
candidate.candidate.candidate_id.clone(), - 21007
), - 21008
("comment".into(), "Keep this reviewed wording".into()), - 21009
("path".into(), "result.txt".into()), - 21010
("line_start".into(), "1".into()), - 21011
]), - 21012
}) - 21013
.unwrap(); - 21014
let history = list_sandbox_candidate_comments( - 21015
State(state.clone()), - 21016
Path(("session-1".into(), candidate.candidate.candidate_id.clone())), - 21017
) - 21018
.await; - 21019
assert_eq!(history.status(), StatusCode::OK); - 21020
let history: serde_json::Value = serde_json::from_slice( - 21021
&axum::body::to_bytes(history.into_body(), 64 * 1024) - 21022
.await - 21023
.unwrap(), - 21024
) - 21025
.unwrap(); - 21026
assert_eq!(history["comments"][0]["text"], "Keep this reviewed wording"); - 21027
assert_eq!(history["comments"][0]["line_start"], 1); - 21028
let response = promote_sandbox_candidate( - 21029
State(state), - 21030
Path("session-1".into()), - 21031
Json(SandboxPromotionBody { - 21032
candidate_id: candidate.candidate.candidate_id, - 21033
files: vec!["result.txt".into()], - 21034
}), - 21035
) - 21036
.await; - 21037
assert_eq!(response.status(), StatusCode::OK); - 21038
assert_eq!( - 21039
tokio::fs::read_to_string(dir.path().join("result.txt")) - 21040
.await - 21041
.unwrap(), - 21042
"reviewed" - 21043
); - 21044
} - 21045
- 21046
#[tokio::test] - 21047
async fn saved_human_comment_revision_fails_before_run_without_provider() { - 21048
vak_config::paths::isolate_home_for_tests(); - 21049
let dir = tempfile::tempdir().unwrap(); - 21050
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 21051
core.set_sessions_home(dir.path().join("home")); - 21052
seed_bound_result(&core, "session-1", "exec-1"); - 21053
let state = AppState::new(core.clone()); - 21054
let scratch = dir.path().join(".vak/scratch/e1"); - 21055
tokio::fs::create_dir_all(&scratch).await.unwrap(); - 21056
tokio::fs::write(scratch.join("result.txt"), "saved candidate") - 21057
.await - 21058
.unwrap(); - 21059
let candidate = export_candidate(&state).await; - 21060
let session_path = core - 21061
.sessions_home() - 21062
.join("sessions") - 21063
.join(vak_core::memory::hash_cwd(core.cwd())) - 21064
.join("session-1.jsonl"); - 21065
let comment_id = "comment-from-asha"; - 21066
let mut log = SessionLog::open(session_path.clone()).unwrap(); - 21067
log.append_activity(vak_session::ActivityRecord { - 21068
activity_id: comment_id.into(), - 21069
turn: None, - 21070
kind: vak_session::ActivityKind::CandidateComment, - 21071
status: vak_session::ActivityStatus::Succeeded, - 21072
label: "Candidate comment".into(), - 21073
detail: None, - 21074
data: std::collections::BTreeMap::from([ - 21075
("actor_id".into(), "person-2".into()), - 21076
("actor_name".into(), "Asha".into()), - 21077
( - 21078
"candidate_id".into(), - 21079
candidate.candidate.candidate_id.clone(), - 21080
), - 21081
( - 21082
"candidate_digest".into(), - 21083
candidate.candidate_digest.clone(), - 21084
), - 21085
("comment".into(), "Please make the opening warmer".into()), - 21086
("path".into(), "result.txt".into()), - 21087
("line_start".into(), "1".into()), - 21088
]), - 21089
}) - 21090
.unwrap(); - 21091
drop(log); - 21092
let historical = SessionLog::open_read_only(session_path.clone()).unwrap(); - 21093
register_handle( - 21094
&state, - 21095
"session-1".into(), - 21096
historical, - 21097
core.cwd().to_path_buf(), - 21098
core.clone(), - 21099
); - 21100
core.set_route("no-such-provider".into(), "no-such-model".into()); - 21101
let response = request_revision_from_candidate_comment( - 21102
State(state.clone()), - 21103
Path(( - 21104
"session-1".into(), - 21105
candidate.candidate.candidate_id.clone(), - 21106
comment_id.into(), - 21107
)), - 21108
) - 21109
.await; - 21110
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - 21111
assert!( - 21112
state - 21113
.get("session-1") - 21114
.unwrap() - 21115
.session - 21116
.lock() - 21117
.unwrap() - 21118
.is_some() - 21119
); - 21120
assert!( - 21121
!SessionLog::open_read_only(session_path) - 21122
.unwrap() - 21123
.has_request_admission("revision-from-comment-from-asha") - 21124
); - 21125
} - 21126
- 21127
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 21128
async fn human_feedback_produces_new_candidate_without_workspace_write() { - 21129
vak_config::paths::isolate_home_for_tests(); - 21130
let dir = tempfile::tempdir().unwrap(); - 21131
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 21132
core.set_sessions_home(dir.path().join("home")); - 21133
core.set_permission_mode(vak_config::PermissionMode::FullAccess); - 21134
core.set_route("revision-test".into(), "test-model".into()); - 21135
let worker = std::env::current_exe() - 21136
.unwrap() - 21137
.parent() - 21138
.unwrap() - 21139
.parent() - 21140
.unwrap() - 21141
.join("vak-tool-worker"); - 21142
if !worker.is_file() { - 21143
eprintln!("build vak-tool-worker to exercise this broker integration test"); - 21144
return; - 21145
} - 21146
core.set_tool_worker_exe(worker); - 21147
core.set_provider_instance(Arc::new(RevisionProvider { - 21148
replies: Mutex::new(VecDeque::from(vec![ - 21149
revision_message( - 21150
vec![ContentBlock::ToolUse { - 21151
id: "edit-version-two".into(), - 21152
name: "write".into(), - 21153
input: serde_json::json!({"path": "result.txt", "content": "version two"}), - 21154
}], - 21155
StopReason::ToolUse, - 21156
), - 21157
revision_message( - 21158
vec![ContentBlock::text("Prepared the revised draft for review.")], - 21159
StopReason::EndTurn, - 21160
), - 21161
])), - 21162
})); - 21163
seed_bound_result(&core, "session-1", "exec-1"); - 21164
let state = AppState::new(core.clone()); - 21165
let scratch = dir.path().join(".vak/scratch/e1"); - 21166
tokio::fs::create_dir_all(&scratch).await.unwrap(); - 21167
tokio::fs::write(scratch.join("result.txt"), "version one") - 21168
.await - 21169
.unwrap(); - 21170
let first = export_candidate(&state).await; - 21171
let parent_log = find_session_on_disk(&core, "session-1").unwrap(); - 21172
register_handle( - 21173
&state, - 21174
"session-1".into(), - 21175
parent_log, - 21176
core.cwd().to_path_buf(), - 21177
core.clone(), - 21178
); - 21179
- 21180
let response = dispatch_candidate_revision( - 21181
state.clone(), - 21182
first.clone(), - 21183
"comment-1".into(), - 21184
"Replace the text with version two".into(), - 21185
) - 21186
.await; - 21187
assert_eq!(response.status(), StatusCode::ACCEPTED); - 21188
let newer = tokio::time::timeout(Duration::from_secs(30), async { - 21189
loop { - 21190
let records = vak_sandbox::load_records(&sandbox_records_path(&state)).unwrap(); - 21191
if let Some(record) = records.iter().rev().find_map(|record| match record { - 21192
vak_sandbox::DurableRecord::Candidate(candidate) - 21193
if candidate.parent_candidate_id.as_deref() - 21194
== Some(first.candidate.candidate_id.as_str()) => - 21195
{ - 21196
Some(candidate.clone()) - 21197
} - 21198
_ => None, - 21199
}) { - 21200
break record; - 21201
} - 21202
if let Some(failed) = records.iter().rev().find_map(|record| match record { - 21203
vak_sandbox::DurableRecord::CandidateRevision(revision) - 21204
if revision.comment_id == "comment-1" - 21205
&& revision.status == vak_sandbox::CandidateRevisionStatus::Failed => - 21206
{ - 21207
Some(revision.clone()) - 21208
} - 21209
_ => None, - 21210
}) { - 21211
panic!("revision failed: {:?}", failed.detail); - 21212
} - 21213
tokio::time::sleep(Duration::from_millis(50)).await; - 21214
} - 21215
}) - 21216
.await - 21217
.unwrap(); - 21218
assert_eq!( - 21219
tokio::fs::read_to_string(newer.candidate.source_root.join("result.txt")) - 21220
.await - 21221
.unwrap(), - 21222
"version two" - 21223
); - 21224
assert_eq!( - 21225
newer.candidate.files[0].base_hash, - 21226
first.candidate.files[0].base_hash - 21227
); - 21228
assert!( - 21229
!dir.path().join("result.txt").exists(), - 21230
"revision must not touch the original workspace" - 21231
); - 21232
assert!(newer.revision_session_id.is_some()); - 21233
let accepted = promote_sandbox_candidate( - 21234
State(state.clone()), - 21235
Path("session-1".into()), - 21236
Json(SandboxPromotionBody { - 21237
candidate_id: newer.candidate.candidate_id, - 21238
files: vec!["result.txt".into()], - 21239
}), - 21240
) - 21241
.await; - 21242
assert_eq!(accepted.status(), StatusCode::OK); - 21243
assert_eq!( - 21244
tokio::fs::read_to_string(dir.path().join("result.txt")) - 21245
.await - 21246
.unwrap(), - 21247
"version two" - 21248
); - 21249
} - 21250
- 21251
/// A revision of an Office draft edits it with `office_apply`, which - 21252
/// writes a draft under the task copy's `.vak/scratch/` and never the - 21253
/// file itself. That draft is the next version of the same draft: the - 21254
/// revision used to fail with "revision did not change candidate files". - 21255
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 21256
async fn office_revision_is_the_next_version_of_the_draft() { - 21257
use sha2::Digest as _; - 21258
vak_config::paths::isolate_home_for_tests(); - 21259
let dir = tempfile::tempdir().unwrap(); - 21260
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 21261
core.set_sessions_home(dir.path().join("home")); - 21262
core.set_route("revision-test".into(), "test-model".into()); - 21263
pin_test_tool_worker(&core); - 21264
let version_one = vak_ooxml::fixtures::docx(); - 21265
let digest: String = sha2::Sha256::digest(&version_one) - 21266
.iter() - 21267
.map(|byte| format!("{byte:02x}")) - 21268
.collect(); - 21269
core.set_provider_instance(Arc::new(RevisionProvider { - 21270
replies: Mutex::new(VecDeque::from(vec![ - 21271
revision_message( - 21272
vec![ContentBlock::ToolUse { - 21273
id: "office-version-two".into(), - 21274
name: "office_apply".into(), - 21275
input: serde_json::json!({ - 21276
"path": "letter.docx", - 21277
"base_digest": digest, - 21278
"ops": [{"op": "replace_paragraph_text", "anchor": "p@1", "text": "Annual Report"}], - 21279
}), - 21280
}], - 21281
StopReason::ToolUse, - 21282
), - 21283
revision_message( - 21284
vec![ContentBlock::text("Retitled the letter.")], - 21285
StopReason::EndTurn, - 21286
), - 21287
])), - 21288
})); - 21289
seed_bound_result(&core, "session-1", "exec-1"); - 21290
let state = AppState::new(core.clone()); - 21291
tokio::fs::write(dir.path().join("letter.docx"), &version_one) - 21292
.await - 21293
.unwrap(); - 21294
let scratch = dir.path().join(".vak/scratch/e1"); - 21295
tokio::fs::create_dir_all(&scratch).await.unwrap(); - 21296
tokio::fs::write(scratch.join("letter.docx"), &version_one) - 21297
.await - 21298
.unwrap(); - 21299
let first = export_candidate(&state).await; - 21300
let parent_log = find_session_on_disk(&core, "session-1").unwrap(); - 21301
register_handle( - 21302
&state, - 21303
"session-1".into(), - 21304
parent_log, - 21305
core.cwd().to_path_buf(), - 21306
core.clone(), - 21307
); - 21308
- 21309
let response = dispatch_candidate_revision( - 21310
state.clone(), - 21311
first.clone(), - 21312
"comment-1".into(), - 21313
"Retitle the letter".into(), - 21314
) - 21315
.await; - 21316
assert_eq!(response.status(), StatusCode::ACCEPTED); - 21317
let newer = tokio::time::timeout(Duration::from_secs(30), async { - 21318
loop { - 21319
let records = vak_sandbox::load_records(&sandbox_records_path(&state)).unwrap(); - 21320
if let Some(failed) = records.iter().rev().find_map(|record| match record { - 21321
vak_sandbox::DurableRecord::CandidateRevision(revision) - 21322
if revision.status == vak_sandbox::CandidateRevisionStatus::Failed => - 21323
{ - 21324
Some(revision.clone()) - 21325
} - 21326
_ => None, - 21327
}) { - 21328
panic!("revision failed: {:?}", failed.detail); - 21329
} - 21330
if let Some(record) = records.iter().rev().find_map(|record| match record { - 21331
vak_sandbox::DurableRecord::Candidate(candidate) - 21332
if candidate.parent_candidate_id.as_deref() - 21333
== Some(first.candidate.candidate_id.as_str()) => - 21334
{ - 21335
Some(candidate.clone()) - 21336
} - 21337
_ => None, - 21338
}) { - 21339
break record; - 21340
} - 21341
tokio::time::sleep(Duration::from_millis(50)).await; - 21342
} - 21343
}) - 21344
.await - 21345
.unwrap(); - 21346
- 21347
let files: Vec<&str> = newer - 21348
.candidate - 21349
.files - 21350
.iter() - 21351
.map(|file| file.path.as_str()) - 21352
.collect(); - 21353
assert_eq!(files, ["letter.docx"]); - 21354
assert_eq!( - 21355
newer.candidate.files[0].base_hash, - 21356
first.candidate.files[0].base_hash - 21357
); - 21358
let version_two = std::fs::read(newer.candidate.source_root.join("letter.docx")).unwrap(); - 21359
let mut package = vak_ooxml::Package::open( - 21360
std::io::Cursor::new(version_two), - 21361
vak_ooxml::Limits::default(), - 21362
) - 21363
.unwrap(); - 21364
let main = package.main_part().to_string(); - 21365
let body = String::from_utf8(package.read_part(&main).unwrap()).unwrap(); - 21366
assert!(body.contains("Annual Report"), "{body}"); - 21367
// One execution's versions are one draft: this is its version 2. - 21368
assert_eq!(newer.execution_id, first.execution_id); - 21369
assert_eq!( - 21370
std::fs::read(dir.path().join("letter.docx")).unwrap(), - 21371
version_one, - 21372
"revision must not touch the original workspace" - 21373
); - 21374
} - 21375
- 21376
#[tokio::test] - 21377
async fn owner_receives_live_comment_refresh_for_open_review() { - 21378
use futures::StreamExt; - 21379
- 21380
vak_config::paths::isolate_home_for_tests(); - 21381
let dir = tempfile::tempdir().unwrap(); - 21382
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 21383
core.set_sessions_home(dir.path().join("home")); - 21384
seed_bound_result(&core, "session-1", "exec-1"); - 21385
let state = AppState::new(core.clone()); - 21386
let path = core - 21387
.sessions_home() - 21388
.join("sessions") - 21389
.join(vak_core::memory::hash_cwd(core.cwd())) - 21390
.join("session-1.jsonl"); - 21391
let session = SessionLog::open(path).unwrap(); - 21392
register_handle( - 21393
&state, - 21394
"session-1".into(), - 21395
session, - 21396
core.cwd().to_path_buf(), - 21397
core, - 21398
); - 21399
let response = coworking_updates( - 21400
State(state.clone()), - 21401
Path("session-1".into()), - 21402
axum::Extension(AuthenticatedPrincipal::Operator), - 21403
axum::http::HeaderMap::new(), - 21404
) - 21405
.await; - 21406
assert_eq!(response.status(), StatusCode::OK); - 21407
let mut events = response.into_body().into_data_stream(); - 21408
let heartbeat = tokio::time::timeout(std::time::Duration::from_secs(3), events.next()) - 21409
.await - 21410
.unwrap() - 21411
.unwrap() - 21412
.unwrap(); - 21413
assert!( - 21414
std::str::from_utf8(&heartbeat) - 21415
.unwrap() - 21416
.contains("event: heartbeat") - 21417
); - 21418
state - 21419
.get("session-1") - 21420
.unwrap() - 21421
.coworking_comments_tx - 21422
.send(()) - 21423
.unwrap(); - 21424
let refresh = tokio::time::timeout(std::time::Duration::from_secs(3), events.next()) - 21425
.await - 21426
.unwrap() - 21427
.unwrap() - 21428
.unwrap(); - 21429
assert!( - 21430
std::str::from_utf8(&refresh) - 21431
.unwrap() - 21432
.contains("event: refresh") - 21433
); - 21434
} - 21435
- 21436
#[test] - 21437
fn sandbox_execution_ledger_survives_live_bus_loss() { - 21438
let dir = tempfile::tempdir().unwrap(); - 21439
let start = AgentEvent::Sandbox(vak_tools::SandboxEvent::ExecutionStarted { - 21440
execution_id: "child-exec".into(), - 21441
owner_session_id: Some("child-session".into()), - 21442
tool: "bash".into(), - 21443
code_preview: "echo hi".into(), - 21444
language: "bash".into(), - 21445
scratch_dir: ".vak/scratch/child-exec".into(), - 21446
}); - 21447
let finish = AgentEvent::Sandbox(vak_tools::SandboxEvent::ExecutionFinished { - 21448
execution_id: "child-exec".into(), - 21449
exit_code: 0, - 21450
duration_ms: 42, - 21451
artifacts: vec!["result.txt".into()], - 21452
}); - 21453
append_session_sandbox_event(dir.path(), "parent-session", &start); - 21454
append_session_sandbox_event(dir.path(), "parent-session", &finish); - 21455
let path = dir.path().join("sandbox/executions/parent-session.jsonl"); - 21456
let lines = std::fs::read_to_string(path).unwrap(); - 21457
assert_eq!(lines.lines().count(), 2); - 21458
assert!(lines.contains("child-session")); - 21459
assert!(lines.contains("ExecutionFinished")); - 21460
} - 21461
- 21462
#[test] - 21463
fn detect_launch_identifies_vite_package_json() {
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.