- 3425
id: id.into(), - 3426
label: label.into(), - 3427
verb: verb.into(), - 3428
data: [("request_id".into(), request_id.into())] - 3429
.into_iter() - 3430
.collect(), - 3431
} - 3432
} - 3433
- 3434
/// Transient webhook failures retry with bounded exponential backoff. - 3435
/// 4xx (except 429) are the receiver's permanent answer and return at once; - 3436
/// network errors, timeouts, 429 and 5xx are retried. - 3437
const WEBHOOK_ATTEMPTS: u32 = 3; - 3438
- 3439
fn webhook_retryable(status: Option<u16>) -> bool { - 3440
match status { - 3441
None => true, - 3442
Some(429) => true, - 3443
Some(c) => c >= 500, - 3444
} - 3445
} - 3446
- 3447
pub(crate) async fn deliver_webhook_packet( - 3448
core: &Core, - 3449
name: &str, - 3450
packet: &DeliveryPacket, - 3451
) -> Result<(), String> { - 3452
let hook = core.config().gateway.webhooks.get(name).ok_or_else(|| { - 3453
let known: Vec<&String> = core.config().gateway.webhooks.keys().collect(); - 3454
format!("unknown webhook '{name}'; configured: {known:?}") - 3455
})?; - 3456
// Fail closed: a configured credential that is missing must not turn - 3457
// into an unauthenticated post of agent output. - 3458
let token = match &hook.token_env { - 3459
Some(env_name) => Some( - 3460
vak_config::get_var(env_name) - 3461
.ok_or_else(|| format!("webhook '{name}' token_env '{env_name}' is not set"))?, - 3462
), - 3463
None => None, - 3464
}; - 3465
let payload = serde_json::json!({ - 3466
"target": format!("webhook:{name}"), - 3467
"text": packet.fallback_markdown, - 3468
"ts": chrono::Utc::now().to_rfc3339(), - 3469
"job_id": packet.job_id, - 3470
"delivery": packet, - 3471
}); - 3472
- 3473
let mut last_error = String::new(); - 3474
for attempt in 0..WEBHOOK_ATTEMPTS { - 3475
if attempt > 0 { - 3476
tokio::time::sleep(Duration::from_millis(400u64 << (attempt - 1))).await; - 3477
} - 3478
let mut req = http_client() - 3479
.post(&hook.url) - 3480
.header("Idempotency-Key", &packet.job_id) - 3481
.json(&payload); - 3482
if let Some(token) = &token { - 3483
req = req.bearer_auth(token); - 3484
} - 3485
match req.send().await { - 3486
Ok(resp) => { - 3487
let status = resp.status(); - 3488
if status.is_success() { - 3489
return Ok(()); - 3490
} - 3491
last_error = format!("webhook '{name}' returned {status}"); - 3492
if !webhook_retryable(Some(status.as_u16())) { - 3493
return Err(last_error); - 3494
} - 3495
} - 3496
Err(e) => { - 3497
last_error = format!("webhook '{name}' post failed: {e}"); - 3498
} - 3499
} - 3500
} - 3501
Err(last_error) - 3502
} - 3503
- 3504
/// Reflection outcome reporting for chat surfaces: the success line keeps - 3505
/// its historical format; config-driven and raced-out skips are routine and - 3506
/// stay silent so a busy gateway does not spam its own log. - 3507
fn log_gateway_reflection(outcome: vak_core::reflection::ReflectionOutcome) { - 3508
use vak_core::reflection::ReflectionOutcome as R; - 3509
match outcome { - 3510
R::Reflected { - 3511
notes_added, - 3512
skills_proposed, - 3513
} => { - 3514
if notes_added > 0 || skills_proposed { - 3515
eprintln!( - 3516
"[gateway] reflection: {notes_added} note(s) persisted, skill queued: {skills_proposed}" - 3517
); - 3518
} - 3519
} - 3520
R::Skipped { reason } => match reason { - 3521
"already-in-flight" | "reflection-disabled" | "memory-writes-disabled" => {} - 3522
other => eprintln!("[gateway] reflection skipped: {other}"), - 3523
}, - 3524
} - 3525
} - 3526
- 3527
#[cfg(test)] - 3528
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 3529
mod tests { - 3530
use super::*; - 3531
use crate::inbox::{INBOX_DIR, sanitize_filename}; - 3532
- 3533
/// The path a note names, quoted after "at path". - 3534
fn saved_path(note: &str) -> Option<&str> { - 3535
note.split_once("at path \"")? - 3536
.1 - 3537
.split_once('"') - 3538
.map(|(path, _)| path) - 3539
} - 3540
- 3541
#[test] - 3542
fn a_channel_reply_for_a_failed_run_is_human_text_never_the_raw_error() { - 3543
let outcome = vak_agent::TurnOutcome::Failed { - 3544
error: vak_llm::LlmError::Network("connection reset by peer at 10.0.0.1:443".into()), - 3545
}; - 3546
let text = outcome_text(&outcome); - 3547
assert!( - 3548
!text.contains("10.0.0.1") && !text.contains("connection reset"), - 3549
"raw provider error leaked into the channel reply: {text:?}" - 3550
); - 3551
assert_eq!( - 3552
text, - 3553
crate::client_events::run_outcome_message(crate::client_events::RunOutcome::Failed) - 3554
); - 3555
} - 3556
- 3557
#[test] - 3558
fn a_channel_reply_for_max_turns_names_the_step_limit_not_a_raw_code() { - 3559
let text = outcome_text(&vak_agent::TurnOutcome::MaxTurnsReached); - 3560
assert_eq!( - 3561
text, - 3562
crate::client_events::run_outcome_message(crate::client_events::RunOutcome::MaxTurns) - 3563
); - 3564
} - 3565
- 3566
#[test] - 3567
fn voice_prompt_is_a_normal_user_message() { - 3568
let prompt = compose_voice_prompt(" turn the lights on "); - 3569
assert_eq!(prompt.role, vak_llm::Role::User); - 3570
assert_eq!(prompt.content.len(), 1); - 3571
match &prompt.content[0] { - 3572
vak_llm::ContentBlock::Text { text } => { - 3573
assert_eq!(text, " turn the lights on "); - 3574
} - 3575
other => panic!("voice prompt used unexpected content block: {other:?}"), - 3576
} - 3577
} - 3578
- 3579
#[test] - 3580
fn a_voice_note_reaches_the_model_only_as_its_text() { - 3581
let prompt = compose_prompt( - 3582
"[voice note not transcribed: no speech was recognized]", - 3583
&[InboundAttachment { - 3584
kind: "audio".into(), - 3585
mime: "audio/ogg".into(), - 3586
data: "not-model-input".into(), - 3587
filename: Some("voice.ogg".into()), - 3588
error: None, - 3589
}], - 3590
std::path::Path::new("."), - 3591
); - 3592
assert_eq!(prompt.content.len(), 1); - 3593
assert!(matches!( - 3594
&prompt.content[0], - 3595
vak_llm::ContentBlock::Text { text } if text.contains("no speech was recognized") - 3596
)); - 3597
} - 3598
- 3599
#[test] - 3600
fn inbound_audio_budget_marks_oversized_payloads() { - 3601
struct Channel; - 3602
impl InboundChannel for Channel { - 3603
fn surface(&self) -> &'static str { - 3604
"test" - 3605
} - 3606
} - 3607
let encoded = "A".repeat(16 * 1024 * 1024 * 4 / 3 + 1); - 3608
let request = InboundRequest::new(&Channel, "chat", "sender", "voice") - 3609
.unwrap() - 3610
.with_attachments(vec![serde_json::json!({"kind":"audio", "data": encoded})]); - 3611
assert_eq!(request.attachments[0]["data"], ""); - 3612
assert_eq!( - 3613
request.attachments[0]["error"], - 3614
"audio attachment exceeds 16 MiB" - 3615
); - 3616
} - 3617
- 3618
/// The console resolved a chat's mode WITHOUT the bot tier, so a bot - 3619
/// pinned narrower than its chat ran narrow and displayed wide — and a - 3620
/// chat with no pin under a bot that had one displayed the workspace's - 3621
/// mode instead of the bot's. Showing a channel as wider than it runs is - 3622
/// the one direction of error that matters here. - 3623
#[test] - 3624
fn a_bot_pin_narrows_the_chat_and_the_console_says_so() { - 3625
use vak_config::PermissionMode::*; - 3626
let ws = tempfile::tempdir().unwrap(); - 3627
vak_config::paths::isolate_home_for_tests(); - 3628
std::fs::create_dir_all(ws.path().join(".vak")).unwrap(); - 3629
std::fs::write( - 3630
ws.path().join(".vak/config.toml"), - 3631
"permission_mode = \"full-access\"\n", - 3632
) - 3633
.unwrap(); - 3634
vak_core::trust::record(ws.path()).unwrap(); - 3635
- 3636
// Chat asks for more than its bot allows: the bot wins. - 3637
let r = resolve_channel_permission(ws.path(), Some(FullAccess), Some(ReadOnly)); - 3638
assert_eq!(r.effective, ReadOnly); - 3639
assert_eq!(r.bot_mode, Some(ReadOnly)); - 3640
assert!(r.was_capped(), "a reduced grant must be visible"); - 3641
- 3642
// Chat has no pin: the bot's applies exactly. Nothing was reduced, - 3643
// so this is not a capping event — but the console must still show - 3644
// `workspace-write`, where it used to show the workspace's - 3645
// `full-access` because the bot tier was never consulted. - 3646
let r = resolve_channel_permission(ws.path(), None, Some(WorkspaceWrite)); - 3647
assert_eq!(r.effective, WorkspaceWrite); - 3648
assert_eq!(r.workspace_mode, FullAccess); - 3649
assert!(!r.was_capped()); - 3650
- 3651
// A chat narrower than its bot is not "capped" — it got what it asked. - 3652
let r = resolve_channel_permission(ws.path(), Some(ReadOnly), Some(FullAccess)); - 3653
assert_eq!(r.effective, ReadOnly); - 3654
assert!(!r.was_capped()); - 3655
} - 3656
- 3657
/// The workspace ceiling still wins over both, in either order. - 3658
#[test] - 3659
fn the_workspace_ceiling_is_never_escaped_by_a_bot_or_a_chat() { - 3660
use vak_config::PermissionMode::*; - 3661
let ws = tempfile::tempdir().unwrap(); - 3662
vak_config::paths::isolate_home_for_tests(); - 3663
std::fs::create_dir_all(ws.path().join(".vak")).unwrap(); - 3664
std::fs::write( - 3665
ws.path().join(".vak/config.toml"), - 3666
"permission_mode = \"read-only\"\n", - 3667
) - 3668
.unwrap(); - 3669
vak_core::trust::record(ws.path()).unwrap(); - 3670
- 3671
for (chat, bot) in [ - 3672
(Some(FullAccess), Some(FullAccess)), - 3673
(Some(FullAccess), None), - 3674
(None, Some(FullAccess)), - 3675
(None, None), - 3676
] { - 3677
let r = resolve_channel_permission(ws.path(), chat, bot); - 3678
assert_eq!(r.effective, ReadOnly, "chat={chat:?} bot={bot:?}"); - 3679
} - 3680
} - 3681
- 3682
/// `forward` with no usable target is not representable: both the - 3683
/// constructor and the setter collapse it to `deny`, the same rule the - 3684
/// config loader applies. - 3685
#[test] - 3686
fn an_unbacked_forward_policy_resolves_to_deny() { - 3687
let timeout = Duration::from_secs(300); - 3688
for approver in [None, Some(""), Some(" "), Some("no-colon")] { - 3689
let policy = ApprovalPolicy::resolve("forward", approver, timeout); - 3690
assert_eq!(policy.approvals, "deny", "approver={approver:?}"); - 3691
assert!(policy.approver.is_none()); - 3692
} - 3693
let policy = ApprovalPolicy::resolve("forward", Some("telegram:42"), timeout); - 3694
assert_eq!(policy.approvals, "forward"); - 3695
assert_eq!(policy.approver.as_deref(), Some("telegram:42")); - 3696
} - 3697
- 3698
/// Regression for the classic serde `Option<Option<T>>` trap: a plain - 3699
/// double-`Option` field can't tell "the key was never sent" apart - 3700
/// from "the key was sent as `null`" — both collapse to the outer - 3701
/// `None`. `deserialize_present` is the fix; this locks in all three - 3702
/// states a PATCH body actually needs. - 3703
#[test] - 3704
fn deserialize_present_distinguishes_absent_null_and_value() { - 3705
#[derive(serde::Deserialize)] - 3706
struct Body { - 3707
#[serde(default, deserialize_with = "deserialize_present")] - 3708
field: Option<Option<String>>, - 3709
} - 3710
- 3711
let absent: Body = serde_json::from_str("{}").unwrap(); - 3712
assert_eq!(absent.field, None, "key never sent must mean 'leave alone'"); - 3713
- 3714
let explicit_null: Body = serde_json::from_str(r#"{"field": null}"#).unwrap(); - 3715
assert_eq!( - 3716
explicit_null.field, - 3717
Some(None), - 3718
"explicit null must mean 'clear it', not be indistinguishable from absent" - 3719
); - 3720
- 3721
let set: Body = serde_json::from_str(r#"{"field": "x"}"#).unwrap(); - 3722
assert_eq!(set.field, Some(Some("x".to_string()))); - 3723
} - 3724
- 3725
#[test] - 3726
fn bare_verdicts_have_no_gate_id() { - 3727
assert_eq!(parse_verdict("yes"), Some((true, None))); - 3728
assert_eq!(parse_verdict(" NO "), Some((false, None))); - 3729
assert_eq!(parse_verdict("approve"), Some((true, None))); - 3730
} - 3731
- 3732
#[test] - 3733
fn addressed_verdict_extracts_single_short_token() { - 3734
assert_eq!( - 3735
parse_verdict("yes ab12cd34"), - 3736
Some((true, Some("ab12cd34".into()))) - 3737
); - 3738
assert_eq!( - 3739
parse_verdict("no deadbeef"), - 3740
Some((false, Some("deadbeef".into()))) - 3741
); - 3742
} - 3743
- 3744
#[test] - 3745
fn prose_after_verdict_is_never_an_id() { - 3746
assert_eq!(parse_verdict("yes please do it now"), Some((true, None))); - 3747
assert_eq!(parse_verdict("no way"), Some((false, None))); - 3748
} - 3749
- 3750
#[test] - 3751
fn chatter_is_not_a_verdict() { - 3752
assert_eq!(parse_verdict("sure thing"), None); - 3753
assert_eq!(parse_verdict(""), None); - 3754
assert_eq!(parse_verdict("approved!"), None); - 3755
} - 3756
- 3757
fn document(name: &str, bytes: &[u8]) -> InboundAttachment { - 3758
use base64::Engine as _; - 3759
InboundAttachment { - 3760
mime: "application/octet-stream".into(), - 3761
data: base64::engine::general_purpose::STANDARD.encode(bytes), - 3762
filename: Some(name.into()), - 3763
kind: "document".into(), - 3764
error: None, - 3765
} - 3766
} - 3767
- 3768
fn note(msg: &vak_llm::Message) -> &str { - 3769
let vak_llm::ContentBlock::Text { text } = &msg.content[1] else { - 3770
unreachable!("expected a text block for a document attachment"); - 3771
}; - 3772
text - 3773
} - 3774
- 3775
#[test] - 3776
fn small_text_document_is_inlined_as_a_fenced_text_block() { - 3777
let workspace = tempfile::tempdir().unwrap(); - 3778
let msg = compose_prompt( - 3779
"check this", - 3780
&[document("notes.py", b"print('hi')")], - 3781
workspace.path(), - 3782
); - 3783
let text = note(&msg); - 3784
assert!(text.contains("Attached file `notes.py`")); - 3785
assert!(text.contains("print('hi')")); - 3786
assert!( - 3787
!workspace.path().join(INBOX_DIR).exists(), - 3788
"inlined text is not saved" - 3789
); - 3790
} - 3791
- 3792
#[test] - 3793
fn large_text_is_saved_to_the_inbox_not_inlined() { - 3794
let workspace = tempfile::tempdir().unwrap(); - 3795
let huge = "x".repeat(DOCUMENT_INLINE_MAX_BYTES + 1); - 3796
let msg = compose_prompt( - 3797
"check this", - 3798
&[document("notes.py", huge.as_bytes())], - 3799
workspace.path(), - 3800
); - 3801
let text = note(&msg); - 3802
assert!( - 3803
!text.contains("xxxx"), - 3804
"the raw content must not be inlined" - 3805
); - 3806
let saved = saved_path(text).unwrap(); - 3807
assert!(saved.ends_with("-notes.py"), "{text}"); - 3808
assert_eq!( - 3809
std::fs::read_to_string(workspace.path().join(saved)).unwrap(), - 3810
huge - 3811
); - 3812
} - 3813
- 3814
#[test] - 3815
fn an_office_file_is_saved_and_named_never_inlined() { - 3816
let workspace = tempfile::tempdir().unwrap(); - 3817
let bytes = vak_ooxml::fixtures::docx(); - 3818
let msg = compose_prompt( - 3819
"summarise", - 3820
&[document("Q3 report.docx", &bytes)], - 3821
workspace.path(), - 3822
); - 3823
let text = note(&msg); - 3824
assert!(text.contains("Read it with doc_read"), "{text}"); - 3825
assert!(text.contains("-Q3 report.docx"), "{text}"); - 3826
assert!( - 3827
!text.contains("PK"), - 3828
"no package bytes in the prompt: {text}" - 3829
); - 3830
let inbox = workspace.path().join(INBOX_DIR); - 3831
let entries: Vec<_> = std::fs::read_dir(&inbox).unwrap().collect(); - 3832
assert_eq!(entries.len(), 1); - 3833
let path = entries[0].as_ref().unwrap().path(); - 3834
assert_eq!(std::fs::read(path).unwrap(), bytes); - 3835
- 3836
// The same bytes again are the same file, not a second copy. - 3837
let again = compose_prompt( - 3838
"again", - 3839
&[document("Q3 report.docx", &bytes)], - 3840
workspace.path(), - 3841
); - 3842
assert_eq!( - 3843
note(&again).replace("again", ""), - 3844
text.replace("summarise", "") - 3845
); - 3846
assert_eq!(std::fs::read_dir(&inbox).unwrap().count(), 1); - 3847
} - 3848
- 3849
#[test] - 3850
fn other_binary_files_are_saved_and_described_honestly() { - 3851
let workspace = tempfile::tempdir().unwrap(); - 3852
let msg = compose_prompt( - 3853
"look", - 3854
&[document("scan.pdf", b"%PDF-1.7\x00\xff\xfe binary")], - 3855
workspace.path(), - 3856
); - 3857
let text = note(&msg); - 3858
assert!(text.contains("not a text or Open XML file"), "{text}"); - 3859
assert!(!text.contains("%PDF"), "{text}"); - 3860
} - 3861
- 3862
#[test] - 3863
fn hostile_filenames_stay_in_the_inbox() { - 3864
let workspace = tempfile::tempdir().unwrap(); - 3865
for name in [ - 3866
"../../etc/passwd", - 3867
"..\\..\\boot.ini", - 3868
"...", - 3869
"a/b/.hidden", - 3870
"sub\x00dir", - 3871
] { - 3872
let msg = compose_prompt("x", &[document(name, b"\x00binary")], workspace.path()); - 3873
let text = note(&msg); - 3874
let saved = saved_path(text).unwrap_or_else(|| panic!("{name}: {text}")); - 3875
assert!( - 3876
!saved[6..].contains('/') && !saved.contains(".."), - 3877
"{name} -> {saved}" - 3878
); - 3879
assert!(workspace.path().join(saved).is_file(), "{name} -> {saved}"); - 3880
} - 3881
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); - 3882
assert_eq!(sanitize_filename("..."), "file"); - 3883
assert_eq!(sanitize_filename(".hidden"), "hidden"); - 3884
} - 3885
- 3886
#[cfg(unix)] - 3887
#[test] - 3888
fn a_planted_inbox_symlink_cannot_redirect_the_write() { - 3889
let workspace = tempfile::tempdir().unwrap(); - 3890
let outside = tempfile::tempdir().unwrap(); - 3891
std::os::unix::fs::symlink(outside.path(), workspace.path().join(INBOX_DIR)).unwrap(); - 3892
let msg = compose_prompt("x", &[document("a.bin", b"\x00")], workspace.path()); - 3893
assert!(note(&msg).contains("could not be saved"), "{}", note(&msg)); - 3894
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0); - 3895
} - 3896
- 3897
#[test] - 3898
fn a_document_over_the_channel_cap_is_not_received() { - 3899
let workspace = tempfile::tempdir().unwrap(); - 3900
let big = vec![0u8; INBOUND_DOCUMENT_MAX_BYTES + 1]; - 3901
let msg = compose_prompt("x", &[document("big.bin", &big)], workspace.path()); - 3902
assert!(note(&msg).contains("channel limit; not received")); - 3903
assert!(!workspace.path().join(INBOX_DIR).exists()); - 3904
} - 3905
- 3906
#[test] - 3907
fn image_attachment_still_becomes_vision_content() { - 3908
let msg = compose_prompt( - 3909
"look", - 3910
&[InboundAttachment { - 3911
mime: "image/png".into(), - 3912
data: "aGVsbG8=".into(), - 3913
filename: None, - 3914
kind: "image".into(), - 3915
error: None, - 3916
}], - 3917
std::path::Path::new("."), - 3918
); - 3919
assert!(matches!( - 3920
msg.content[1], - 3921
vak_llm::ContentBlock::Image { .. } - 3922
)); - 3923
} - 3924
- 3925
#[test] - 3926
fn webhook_retry_matrix() { - 3927
assert!(webhook_retryable(None), "network error retries"); - 3928
assert!(webhook_retryable(Some(429))); - 3929
assert!(webhook_retryable(Some(503))); - 3930
assert!(!webhook_retryable(Some(401)), "auth is permanent"); - 3931
assert!(!webhook_retryable(Some(404))); - 3932
assert!(!webhook_retryable(Some(200))); - 3933
} - 3934
- 3935
#[test] - 3936
fn legacy_session_map_loads_as_versioned_binding_records() { - 3937
let dir = tempfile::tempdir().unwrap(); - 3938
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 3939
core.set_sessions_home(dir.path().join("home")); - 3940
let path = bindings_path(&core.shared_data_home()); - 3941
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 3942
std::fs::write(&path, r#"{"telegram:42":"old-session"}"#).unwrap(); - 3943
let gateway = GatewayState::load(&core, true); - 3944
let snapshot = gateway.snapshot(); - 3945
assert_eq!(snapshot.len(), 1); - 3946
assert_eq!(snapshot[0].0, "telegram:42"); - 3947
assert_eq!(snapshot[0].1.session_id.as_deref(), Some("old-session")); - 3948
assert!(snapshot[0].1.provider.is_none()); - 3949
} - 3950
- 3951
#[tokio::test] - 3952
async fn route_change_rotates_binding_without_rewriting_old_session() { - 3953
let dir = tempfile::tempdir().unwrap(); - 3954
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 3955
core.set_sessions_home(dir.path().join("home")); - 3956
let state = AppState::new(core.clone()); - 3957
let old = core - 3958
.start_session_with_route("provider-a".into(), "model-a".into()) - 3959
.await - 3960
.unwrap(); - 3961
let old_id = old.header().unwrap().session_id.clone(); - 3962
crate::register_handle( - 3963
&state, - 3964
old_id.clone(), - 3965
old, - 3966
core.cwd().clone(), - 3967
core.clone(), - 3968
); - 3969
state.gateway.bind( - 3970
&core, - 3971
"telegram:42".into(), - 3972
old_id.clone(), - 3973
"old-revision".into(), - 3974
); - 3975
state.gateway.set_route_override( - 3976
&core, - 3977
"telegram:42".into(), - 3978
Some(("provider-b".into(), "model-b".into())), - 3979
); - 3980
- 3981
let fresh = resolve_session(&state, &core, "telegram:42").await.unwrap(); - 3982
assert_ne!(fresh.id, old_id); - 3983
{ - 3984
let lock = fresh - 3985
.session - 3986
.lock() - 3987
.unwrap_or_else(std::sync::PoisonError::into_inner); - 3988
let contract = &lock.as_ref().unwrap().header().unwrap().contract; - 3989
assert_eq!(contract.provider, "provider-b"); - 3990
assert_eq!(contract.model, "model-b"); - 3991
} - 3992
let old_path = - 3993
vak_session::SessionPath::new_session_file(&core.sessions_home(), core.cwd(), &old_id); - 3994
assert!(old_path.is_file(), "old append-only ledger remains intact"); - 3995
} - 3996
- 3997
#[tokio::test] - 3998
async fn agent_change_rotates_binding_without_rewriting_old_session() { - 3999
let dir = tempfile::tempdir().unwrap(); - 4000
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 4001
core.set_sessions_home(dir.path().join("home")); - 4002
let state = AppState::new(core.clone()); - 4003
let old = core - 4004
.start_session_with_route("provider-a".into(), "model-a".into()) - 4005
.await - 4006
.unwrap(); - 4007
let old_id = old.header().unwrap().session_id.clone(); - 4008
crate::register_handle( - 4009
&state, - 4010
old_id.clone(), - 4011
old, - 4012
core.cwd().clone(), - 4013
core.clone(), - 4014
); - 4015
state - 4016
.gateway - 4017
.bind(&core, "telegram:42".into(), old_id.clone(), "rev".into()); - 4018
- 4019
// Core is re-resolved with the new agent identity (e.g. Researcher) - 4020
let researcher_identity = vak_session::types::AgentIdentity { - 4021
id: "researcher".into(), - 4022
revision: 1, - 4023
name: "Researcher".into(), - 4024
character: "vak".into(), - 4025
personality: "curious".into(), - 4026
animation: "subtle".into(), - 4027
voice: "default".into(), - 4028
behaviour: "thorough".into(), - 4029
responsibilities: "deep research".into(), - 4030
instructions: String::new(), - 4031
}; - 4032
let core_researcher = core.clone().with_agent_identity(Some(researcher_identity)); - 4033
- 4034
let fresh = resolve_session(&state, &core_researcher, "telegram:42") - 4035
.await - 4036
.unwrap(); - 4037
assert_ne!(fresh.id, old_id); - 4038
{ - 4039
let lock = fresh - 4040
.session - 4041
.lock() - 4042
.unwrap_or_else(std::sync::PoisonError::into_inner); - 4043
let agent = lock - 4044
.as_ref() - 4045
.unwrap() - 4046
.header() - 4047
.unwrap() - 4048
.agent - 4049
.as_ref() - 4050
.unwrap(); - 4051
assert_eq!(agent.id, "researcher"); - 4052
} - 4053
let old_path = - 4054
vak_session::SessionPath::new_session_file(&core.sessions_home(), core.cwd(), &old_id); - 4055
assert!(old_path.is_file(), "old agent ledger remains intact"); - 4056
} - 4057
- 4058
/// One persona, not two. The `identity` prompt block wins over the - 4059
/// deprecated `VoiceConfig.persona`, and the legacy field still works - 4060
/// when no prompt tier sets one (docs/design/45-prompt-layers.md). - 4061
#[tokio::test] - 4062
async fn identity_block_is_the_persona_with_voice_config_as_fallback() { - 4063
let dir = tempfile::tempdir().unwrap(); - 4064
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 4065
core.set_sessions_home(dir.path().join("home")); - 4066
let state = AppState::new(core.clone()); - 4067
- 4068
state.gateway.bot_upsert( - 4069
&core, - 4070
Bot { - 4071
id: "support".into(), - 4072
surface: "telegram".into(), - 4073
label: "Support".into(), - 4074
token_env: "BOT_TOKEN__SUPPORT".into(), - 4075
agent_id: None, - 4076
policy: Default::default(), - 4077
permission_mode: None, - 4078
route: None, - 4079
workspace: None, - 4080
voice: Some(vak_config::VoiceConfig { - 4081
voice_name: Some("Kore".into()), - 4082
persona: Some("legacy persona".into()), - 4083
..Default::default() - 4084
}), - 4085
prompt: Default::default(), - 4086
}, - 4087
); - 4088
state.gateway.allowlist_approve( - 4089
&core, - 4090
"telegram:42", - 4091
core.cwd().clone(), - 4092
None, - 4093
None, - 4094
None, - 4095
Default::default(), - 4096
Some("support".into()), - 4097
true, - 4098
"test", - 4099
); - 4100
- 4101
// No prompt tier yet: the legacy field still drives the voice. - 4102
assert_eq!( - 4103
state.gateway.resolve_persona("telegram:42").as_deref(), - 4104
Some("legacy persona") - 4105
); - 4106
- 4107
// Give the bot an identity block; it takes over. - 4108
let mut bot = state.gateway.bot_get("support").unwrap(); - 4109
bot.prompt.identity = Some("You are the ACME support bot: warm and brief.".into()); - 4110
state.gateway.bot_upsert(&core, bot); - 4111
assert_eq!( - 4112
state.gateway.resolve_persona("telegram:42").as_deref(), - 4113
Some("You are the ACME support bot: warm and brief.") - 4114
); - 4115
- 4116
// The chat tier is narrower still. - 4117
let entry = state.gateway.allowlist_get("telegram:42").unwrap(); - 4118
state.gateway.allowlist_patch( - 4119
&core, - 4120
"telegram:42", - 4121
entry.workspace, - 4122
Some(entry.agent_id), - 4123
entry.route, - 4124
entry.permission_mode, - 4125
entry.policy, - 4126
Some(entry.bot_id), - 4127
Some(entry.inherit_bot_policy), - 4128
Some(entry.voice), - 4129
Some(vak_core::prompts::LayerContent { - 4130
identity: Some("You are ACME support for this VIP chat.".into()), - 4131
..Default::default() - 4132
}), - 4133
); - 4134
assert_eq!( - 4135
state.gateway.resolve_persona("telegram:42").as_deref(), - 4136
Some("You are ACME support for this VIP chat.") - 4137
); - 4138
// Voice *name* selection is untouched — that was never duplicated. - 4139
assert_eq!( - 4140
state - 4141
.gateway - 4142
.resolve_voice("telegram:42") - 4143
.and_then(|v| v.voice_name) - 4144
.as_deref(), - 4145
Some("Kore") - 4146
); - 4147
} - 4148
- 4149
/// A chat binding is implicit — the operator never named the session — - 4150
/// so an edited prompt layer rotates it rather than failing, and the old - 4151
/// A chat binding preserves its session across prompt layer edits, - 4152
/// refreshing capabilities dynamically without forced session rotation. - 4153
#[tokio::test] - 4154
async fn prompt_layer_change_preserves_binding_without_forced_rotation() { - 4155
let dir = tempfile::tempdir().unwrap(); - 4156
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 4157
core.set_sessions_home(dir.path().join("home")); - 4158
let session = core - 4159
.start_session_with_route(core.effective_provider(), core.effective_model()) - 4160
.await - 4161
.unwrap(); - 4162
let old_id = session.header().unwrap().session_id.clone(); - 4163
assert!( - 4164
!session.header().unwrap().contract.prompt_layers.is_empty(), - 4165
"a new session must record which prompt layers it froze" - 4166
); - 4167
let state = AppState::new(core.clone()); - 4168
crate::register_handle( - 4169
&state, - 4170
old_id.clone(), - 4171
session, - 4172
core.cwd().clone(), - 4173
core.clone(), - 4174
); - 4175
state.gateway.bind( - 4176
&core, - 4177
"telegram:42".into(), - 4178
old_id.clone(), - 4179
"route-revision".into(), - 4180
); - 4181
- 4182
// Unchanged workspace: the same session is reused. - 4183
let same = resolve_session(&state, &core, "telegram:42").await.unwrap(); - 4184
assert_eq!(same.id, old_id); - 4185
- 4186
// Now edit a layer under the running binding. - 4187
let prompts_dir = core.cwd().join(".vak/prompts"); - 4188
std::fs::create_dir_all(&prompts_dir).unwrap(); - 4189
std::fs::write(prompts_dir.join("guardrails.md"), "- never touch infra/\n").unwrap(); - 4190
- 4191
let fresh = resolve_session(&state, &core, "telegram:42").await.unwrap(); - 4192
assert_eq!( - 4193
fresh.id, old_id, - 4194
"prompt layer change preserves session without forced rotation" - 4195
); - 4196
let old_path = - 4197
vak_session::SessionPath::new_session_file(&core.sessions_home(), core.cwd(), &old_id); - 4198
assert!(old_path.is_file(), "append-only ledger remains intact"); - 4199
} - 4200
- 4201
#[tokio::test] - 4202
async fn capability_contract_change_rotates_legacy_binding() { - 4203
let dir = tempfile::tempdir().unwrap(); - 4204
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 4205
core.set_sessions_home(dir.path().join("home")); - 4206
let current = core - 4207
.start_session_with_route("provider-a".into(), "model-a".into()) - 4208
.await - 4209
.unwrap(); - 4210
let mut legacy_header = current.header().unwrap().clone(); - 4211
legacy_header.session_id = "legacy-session".into(); - 4212
legacy_header.contract.app_version = "0.11.35".into(); - 4213
legacy_header.contract.capabilities.clear(); - 4214
let legacy_path = vak_session::SessionPath::new_session_file( - 4215
&core.sessions_home(), - 4216
core.cwd(), - 4217
&legacy_header.session_id, - 4218
); - 4219
let legacy = vak_session::SessionLog::create(legacy_path.clone(), legacy_header).unwrap(); - 4220
let state = AppState::new(core.clone()); - 4221
crate::register_handle( - 4222
&state, - 4223
"legacy-session".into(), - 4224
legacy, - 4225
core.cwd().clone(), - 4226
core.clone(), - 4227
); - 4228
state.gateway.bind( - 4229
&core, - 4230
"telegram:42".into(), - 4231
"legacy-session".into(), - 4232
"route-revision".into(), - 4233
); - 4234
- 4235
let fresh = resolve_session(&state, &core, "telegram:42").await.unwrap(); - 4236
- 4237
assert_ne!(fresh.id, "legacy-session"); - 4238
let lock = fresh - 4239
.session - 4240
.lock() - 4241
.unwrap_or_else(std::sync::PoisonError::into_inner); - 4242
let contract = &lock.as_ref().unwrap().header().unwrap().contract; - 4243
assert_eq!(contract.capabilities, core.capability_descriptors()); - 4244
assert!(legacy_path.is_file(), "legacy ledger remains append-only"); - 4245
} - 4246
- 4247
// ---- Allowlist store (docs/design/34-channel-onboarding.md) ----------- - 4248
- 4249
fn core_with_config(toml: &str) -> (tempfile::TempDir, Core) { - 4250
let dir = tempfile::tempdir().unwrap(); - 4251
let cwd = dir.path().to_path_buf(); - 4252
std::fs::create_dir_all(cwd.join(".vak")).unwrap(); - 4253
std::fs::write(cwd.join(".vak/config.toml"), toml).unwrap(); - 4254
let core = Core::new_with_trust(cwd.clone(), true).unwrap(); - 4255
core.set_sessions_home(dir.path().join("home")); - 4256
(dir, core) - 4257
} - 4258
- 4259
#[test] - 4260
fn allowlist_seeds_from_config_only_when_file_absent() { - 4261
let (_dir, core) = - 4262
core_with_config("[gateway]\nchat_allowlist = [\"telegram:1\", \"telegram:2\"]\n"); - 4263
let gw = GatewayState::load(&core, true); - 4264
let mut entries = gw.allowlist_snapshot(); - 4265
entries.sort_by(|a, b| a.key.cmp(&b.key)); - 4266
assert_eq!(entries.len(), 2); - 4267
assert_eq!(entries[0].key, "telegram:1"); - 4268
assert_eq!(entries[0].status, AllowlistStatus::Allowed); - 4269
assert_eq!(entries[0].added_by, "config_import"); - 4270
assert!(allowlist_path(&core.shared_data_home()).is_file()); - 4271
- 4272
// Once the file exists, it is authoritative: a config change is not - 4273
// re-imported on the next load. - 4274
gw.allowlist_revoke(&core, "telegram:1"); - 4275
drop(gw); - 4276
let gw2 = GatewayState::load(&core, true); - 4277
let keys: Vec<String> = gw2 - 4278
.allowlist_snapshot() - 4279
.into_iter() - 4280
.map(|e| e.key) - 4281
.collect(); - 4282
assert_eq!(keys, vec!["telegram:2"]); - 4283
} - 4284
- 4285
#[test] - 4286
fn allowlist_route_resolves_the_configured_agent_identity() { - 4287
let (_dir, core) = core_with_config("[memory]\nreflection = false\n"); - 4288
std::fs::write( - 4289
core.cwd().join(".vak/agents.json"), - 4290
serde_json::json!([{ - 4291
"id": "support", - 4292
"revision": 3, - 4293
"name": "Support", - 4294
"character": "orb", - 4295
"personality": "calm", - 4296
"behaviour": "helpful", - 4297
"responsibilities": "support", - 4298
"animation": "off", - 4299
"voice": "default" - 4300
}]) - 4301
.to_string(), - 4302
) - 4303
.unwrap(); - 4304
let gw = GatewayState::load(&core, true); - 4305
gw.allowlist_approve( - 4306
&core, - 4307
"telegram:agent", - 4308
core.cwd().clone(), - 4309
Some("support".into()), - 4310
None, - 4311
None, - 4312
Default::default(), - 4313
None, - 4314
true, - 4315
"test", - 4316
); - 4317
let resolved = gw.core_for_entry(&core, "telegram:agent").unwrap(); - 4318
assert_eq!( - 4319
resolved.agent_identity().map(|agent| agent.id.as_str()), - 4320
Some("support") - 4321
); - 4322
} - 4323
- 4324
#[test] - 4325
fn bot_agent_identity_resolves_when_chat_inherits_bot() { - 4326
let (_dir, core) = core_with_config("[memory]\nreflection = false\n"); - 4327
std::fs::write( - 4328
core.cwd().join(".vak/agents.json"), - 4329
serde_json::json!([{ - 4330
"id": "researcher", - 4331
"revision": 2, - 4332
"name": "Researcher", - 4333
"character": "orb", - 4334
"personality": "curious", - 4335
"behaviour": "thorough", - 4336
"responsibilities": "research", - 4337
"animation": "off", - 4338
"voice": "default" - 4339
}]) - 4340
.to_string(), - 4341
) - 4342
.unwrap(); - 4343
let gw = GatewayState::load(&core, true); - 4344
gw.bot_upsert( - 4345
&core, - 4346
Bot { - 4347
id: "res-bot".into(), - 4348
surface: "telegram".into(), - 4349
label: "Researcher Bot".into(), - 4350
token_env: "BOT_TOKEN__RES".into(), - 4351
agent_id: Some("researcher".into()), - 4352
..Default::default() - 4353
}, - 4354
); - 4355
gw.allowlist_approve( - 4356
&core, - 4357
"telegram:res-chat", - 4358
core.cwd().clone(), - 4359
None, - 4360
None, - 4361
None, - 4362
Default::default(), - 4363
Some("res-bot".into()), - 4364
true, - 4365
"test", - 4366
); - 4367
let resolved = gw.core_for_entry(&core, "telegram:res-chat").unwrap(); - 4368
assert_eq!( - 4369
resolved.agent_identity().map(|agent| agent.id.as_str()), - 4370
Some("researcher") - 4371
); - 4372
- 4373
// Even if legacy entry was stamped with "vak", inherit_bot_policy allows the bot's agent to shine through - 4374
gw.allowlist_patch( - 4375
&core, - 4376
"telegram:res-chat", - 4377
Some(core.cwd().clone()), - 4378
Some(Some("vak".into())), - 4379
None, - 4380
None, - 4381
Default::default(), - 4382
Some(Some("res-bot".into())), - 4383
Some(true), - 4384
None, - 4385
None, - 4386
); - 4387
let resolved_legacy = gw.core_for_entry(&core, "telegram:res-chat").unwrap(); - 4388
assert_eq!( - 4389
resolved_legacy - 4390
.agent_identity() - 4391
.map(|agent| agent.id.as_str()), - 4392
Some("researcher") - 4393
); - 4394
- 4395
// Explicit chat agent override takes precedence - 4396
std::fs::write( - 4397
core.cwd().join(".vak/agents.json"), - 4398
serde_json::json!([ - 4399
{ - 4400
"id": "researcher", - 4401
"revision": 2, - 4402
"name": "Researcher", - 4403
"character": "orb", - 4404
"personality": "curious", - 4405
"behaviour": "thorough", - 4406
"responsibilities": "research", - 4407
"animation": "off", - 4408
"voice": "default" - 4409
}, - 4410
{ - 4411
"id": "support", - 4412
"revision": 2, - 4413
"name": "Support", - 4414
"character": "orb", - 4415
"personality": "helpful", - 4416
"behaviour": "friendly", - 4417
"responsibilities": "support", - 4418
"animation": "off", - 4419
"voice": "default" - 4420
} - 4421
]) - 4422
.to_string(), - 4423
) - 4424
.unwrap();
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.