- 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 - 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());
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.