- 1
//! Regression (live-found): gateway turns with NO SSE subscriber must not - 2
//! be aborted mid-stream when the first event fires. The old broadcast pump - 3
//! treated zero subscribers as fatal, dropped its mpsc receiver, and the - 4
//! agent self-cancelled on the next send — surfacing to chat users as - 5
//! "(aborted)" exactly when the model started a tool call. - 6
- 7
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 8
- 9
use std::collections::VecDeque; - 10
use std::sync::{Arc, Mutex}; - 11
- 12
use tokio_util::sync::CancellationToken; - 13
- 14
use vak_core::Core; - 15
use vak_llm::stream; - 16
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, Usage}; - 17
use vak_llm::{EventStream, LlmError, Provider}; - 18
- 19
struct Scripted { - 20
responses: Mutex<VecDeque<AssistantMessage>>, - 21
} - 22
- 23
#[async_trait::async_trait] - 24
impl Provider for Scripted { - 25
fn name(&self) -> &str { - 26
"scripted" - 27
} - 28
- 29
async fn stream( - 30
&self, - 31
_request: ChatRequest, - 32
_cancel: CancellationToken, - 33
) -> Result<EventStream, LlmError> { - 34
let next = self.responses.lock().unwrap().pop_front(); - 35
let (mut sink, rx) = stream::channel(64); - 36
match next { - 37
Some(m) => { - 38
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 39
sink.close_message(m).await; - 40
} - 41
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 42
} - 43
Ok(rx) - 44
} - 45
} - 46
- 47
fn text(t: &str) -> AssistantMessage { - 48
AssistantMessage { - 49
content: vec![ContentBlock::text(t)], - 50
stop_reason: vak_llm::types::StopReason::EndTurn, - 51
usage: Usage { - 52
input_tokens: 7, - 53
output_tokens: 3, - 54
..Default::default() - 55
}, - 56
model: "test-model".into(), - 57
response_id: None, - 58
} - 59
} - 60
- 61
fn tool_call(id: &str, name: &str, input: serde_json::Value) -> AssistantMessage { - 62
AssistantMessage { - 63
content: vec![ContentBlock::ToolUse { - 64
id: id.into(), - 65
name: name.into(), - 66
input, - 67
}], - 68
stop_reason: vak_llm::types::StopReason::ToolUse, - 69
usage: Usage::default(), - 70
model: "test-model".into(), - 71
response_id: None, - 72
} - 73
} - 74
- 75
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 76
async fn headless_tool_turn_survives_without_subscribers() { - 77
let provider = Arc::new(Scripted { - 78
responses: Mutex::new(VecDeque::from(vec![ - 79
tool_call("t1", "glob", serde_json::json!({"pattern": "*.md"})), - 80
text("found some markdown files"), - 81
])), - 82
}); - 83
- 84
let dir = tempfile::tempdir().unwrap(); - 85
let cwd = dir.path().to_path_buf(); - 86
// Hermetic against the developer's global config (e.g. reflection=true): - 87
// pin learning flags off for deterministic scripted flows. - 88
let _ = std::fs::create_dir_all(cwd.join(".vak")); - 89
let _ = std::fs::write( - 90
cwd.join(".vak/config.toml"), - 91
"permission_mode = \"full-access\"\n[memory]\nreflection = false\n[gateway]\nchat_allowlist_open = true\n", - 92
); - 93
vak_config::paths::isolate_home_for_tests(); - 94
let core = Core::new_with_trust(cwd.clone(), true).unwrap(); - 95
core.set_sessions_home(dir.path().join("home")); - 96
core.set_permission_mode(vak_config::PermissionMode::FullAccess); - 97
core.set_tool_worker_exe(std::path::PathBuf::from(env!( - 98
"CARGO_BIN_EXE_vak-tool-worker" - 99
))); - 100
core.set_provider_instance(provider); - 101
std::mem::forget(dir); - 102
- 103
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 104
let addr = listener.local_addr().unwrap(); - 105
tokio::spawn(async move { - 106
axum::serve(listener, vak_server::gateway_router(core)) - 107
.await - 108
.unwrap(); - 109
}); - 110
let base = format!("http://{addr}"); - 111
let client = reqwest::Client::new(); - 112
- 113
// Deliberately NO /events subscriber: wait:true over inbound only. - 114
let res = client - 115
.post(format!("{base}/gateway/inbound")) - 116
.json(&serde_json::json!({ - 117
"surface": "probe", - 118
"chat": "headless", - 119
"text": "What files are in this project?", - 120
"wait": true, - 121
})) - 122
.send() - 123
.await - 124
.unwrap(); - 125
assert_eq!(res.status(), 200); - 126
let body: serde_json::Value = res.json().await.unwrap(); - 127
assert_eq!(body["state"], "completed"); - 128
assert_ne!( - 129
body["text"], "(aborted)", - 130
"tool event must not kill a subscriber-less run" - 131
); - 132
assert_eq!(body["text"], "found some markdown files"); - 133
- 134
// The tool really executed: glob result is on the ledger. - 135
let sid = body["session_id"].as_str().unwrap().to_string(); - 136
let t: serde_json::Value = client - 137
.get(format!("{base}/sessions/{sid}/transcript")) - 138
.send() - 139
.await - 140
.unwrap() - 141
.json() - 142
.await - 143
.unwrap(); - 144
let raw = serde_json::to_string(&t).unwrap(); - 145
assert!(raw.contains("tool_use") || raw.contains("result"), "{raw}"); - 146
} - 147
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.