- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
use std::collections::VecDeque; - 4
use std::sync::{Arc, Mutex}; - 5
- 6
use tokio::sync::mpsc; - 7
use tokio_util::sync::CancellationToken; - 8
- 9
use tempfile::tempdir; - 10
- 11
use vak_agent::{Agent, AgentConfig, TurnOutcome}; - 12
use vak_hooks::HookDef; - 13
use vak_llm::stream; - 14
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 15
use vak_llm::{EventStream, LlmError, Provider}; - 16
use vak_session::SessionLog; - 17
use vak_session::types::{FrozenContract, SessionHeader}; - 18
use vak_tools::bash::BashTool; - 19
- 20
struct Scripted { - 21
responses: Mutex<VecDeque<AssistantMessage>>, - 22
} - 23
- 24
#[async_trait::async_trait] - 25
impl Provider for Scripted { - 26
fn name(&self) -> &str { - 27
"scripted" - 28
} - 29
- 30
async fn stream( - 31
&self, - 32
_request: ChatRequest, - 33
_cancel: CancellationToken, - 34
) -> Result<EventStream, LlmError> { - 35
let next = self.responses.lock().unwrap().pop_front(); - 36
let (mut sink, rx) = stream::channel(64); - 37
match next { - 38
Some(m) => { - 39
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 40
sink.close_message(m).await; - 41
} - 42
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 43
} - 44
Ok(rx) - 45
} - 46
} - 47
- 48
fn text_msg(t: &str) -> AssistantMessage { - 49
AssistantMessage { - 50
content: vec![ContentBlock::text(t)], - 51
stop_reason: StopReason::EndTurn, - 52
usage: Usage { - 53
input_tokens: 1, - 54
output_tokens: 1, - 55
..Default::default() - 56
}, - 57
model: "test-model".into(), - 58
response_id: None, - 59
} - 60
} - 61
- 62
fn bash_call(id: &str, cmd: &str) -> AssistantMessage { - 63
AssistantMessage { - 64
content: vec![ContentBlock::ToolUse { - 65
id: id.into(), - 66
name: "bash".into(), - 67
input: serde_json::json!({"command": cmd}), - 68
}], - 69
stop_reason: StopReason::ToolUse, - 70
usage: Usage::default(), - 71
model: "test-model".into(), - 72
response_id: None, - 73
} - 74
} - 75
- 76
fn build(responses: Vec<AssistantMessage>, hooks: Option<Vec<HookDef>>) -> Agent { - 77
let dir = tempdir().unwrap(); - 78
let header = SessionHeader { - 79
agent: None, - 80
session_id: "s-hooks".into(), - 81
created_at: chrono::Utc::now(), - 82
cwd: dir.path().to_path_buf(), - 83
parent_session_id: None, - 84
contract_id: None, - 85
work_item_id: None, - 86
conversation: None, - 87
contract: FrozenContract { - 88
app_version: "0".into(), - 89
provider: "scripted".into(), - 90
model: "test-model".into(), - 91
route_ladder: Vec::new(), - 92
route_objective: String::new(), - 93
route_annotations: Vec::new(), - 94
system_prompt: "sys".into(), - 95
permission_mode: "full-access".into(), - 96
capabilities: Vec::new(), - 97
prompt_layers: Vec::new(), - 98
}, - 99
}; - 100
let log = SessionLog::create(dir.path().join("s.jsonl"), header).unwrap(); - 101
let mut cfg = AgentConfig::new("sys"); - 102
cfg.tools = vec![Arc::new(BashTool)]; - 103
cfg.mode = vak_permission::Mode::FullAccess; - 104
cfg.hooks = hooks.map(Arc::new); - 105
cfg.stop_policy = None; - 106
std::mem::forget(dir); - 107
Agent::new( - 108
Arc::new(Scripted { - 109
responses: Mutex::new(responses.into_iter().collect()), - 110
}), - 111
log, - 112
cfg, - 113
) - 114
} - 115
- 116
#[tokio::test] - 117
async fn pre_tool_use_hook_blocks_execution() { - 118
let marker = tempdir().unwrap(); - 119
let marker_path = marker.path().join("ran"); - 120
let hooks = vec![HookDef { - 121
event: vak_hooks::HookEvent::PreToolUse, - 122
matcher: Some(vak_permission::Rule::parse("Bash(touch *)").unwrap()), - 123
command: r#"echo '{"decision":"block","reason":"no touching"}'"#.to_string(), - 124
timeout_ms: 5000, - 125
failure_mode: vak_hooks::HookFailureMode::Open, - 126
refusal: None, - 127
}]; - 128
let marker_path = marker_path.display().to_string(); - 129
let mut agent = build( - 130
vec![ - 131
bash_call("t1", &format!("touch {marker_path}")), - 132
text_msg("adapted"), - 133
], - 134
Some(hooks), - 135
); - 136
- 137
let outcome = agent - 138
.run( - 139
"go", - 140
&Default::default(), - 141
CancellationToken::new(), - 142
mpsc::channel(64).0, - 143
) - 144
.await; - 145
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 146
assert!( - 147
!std::path::Path::new(&marker_path).exists(), - 148
"blocked command must never have executed" - 149
); - 150
let session = agent.session.lock().await; - 151
// Raw ledger: the closed turn's result is a trace line in the - 152
// projection now (docs/design/68-context-engine.md §10). - 153
let result = session - 154
.message_chain() - 155
.iter() - 156
.flat_map(|(_, m)| m.content.iter()) - 157
.find_map(|b| match b { - 158
ContentBlock::ToolResult { - 159
content, is_error, .. - 160
} => Some((content.clone(), *is_error)), - 161
_ => None, - 162
}) - 163
.unwrap(); - 164
assert!(result.1); - 165
assert!(result.0.contains("no touching")); - 166
} - 167
- 168
#[tokio::test] - 169
async fn stop_hook_forces_continuation_once() { - 170
let dir = tempdir().unwrap(); - 171
let flag = dir.path().join("block-once"); - 172
let flag_display = flag.display().to_string(); - 173
// Block the first Stop; after the flag exists, allow it. - 174
let cmd = format!( - 175
"if [ -f {flag_display} ]; then exit 0; else touch {flag_display}; echo '{{\"decision\":\"block\",\"reason\":\"say goodbye first\"}}'; fi" - 176
); - 177
let hooks = vec![HookDef { - 178
event: vak_hooks::HookEvent::Stop, - 179
matcher: None, - 180
command: cmd, - 181
timeout_ms: 5000, - 182
failure_mode: vak_hooks::HookFailureMode::Open, - 183
refusal: None, - 184
}]; - 185
let mut agent = build( - 186
vec![text_msg("first attempt"), text_msg("goodbye")], - 187
Some(hooks), - 188
); - 189
- 190
let outcome = agent - 191
.run( - 192
"go", - 193
&Default::default(), - 194
CancellationToken::new(), - 195
mpsc::channel(64).0, - 196
) - 197
.await; - 198
match outcome { - 199
TurnOutcome::Completed { response } => { - 200
assert_eq!(response.text_content(), "goodbye"); - 201
} - 202
other => panic!("expected completed after continuation, got {other:?}"), - 203
} - 204
let session = agent.session.lock().await; - 205
// Raw ledger: a control nudge is scaffolding for the turn still in - 206
// progress and is dropped once the turn closes - 207
// (docs/design/68-context-engine.md §10). - 208
let texts: Vec<String> = session - 209
.message_chain() - 210
.iter() - 211
.filter(|(_, m)| m.text_content().contains("[stop-hook]")) - 212
.map(|(_, m)| m.text_content()) - 213
.collect(); - 214
assert_eq!(texts.len(), 1, "continuation message must be logged once"); - 215
} - 216
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.