- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
//! The presentation check: an answer the app's own signal/recipe detection - 4
//! says should be a card, written as prose with no card emitted, gets exactly - 5
//! one nudge. Real trigger: a gpt-5.6-luna answer to "how did the Indian stock - 6
//! market perform last week" that was a markdown table plus bullets, with the - 7
//! `emit_*_card` tools offered and unused. - 8
- 9
use std::collections::VecDeque; - 10
use std::sync::Arc; - 11
use std::sync::Mutex; - 12
- 13
use async_trait::async_trait; - 14
use tokio::sync::mpsc; - 15
use tokio_util::sync::CancellationToken; - 16
- 17
use tempfile::tempdir; - 18
- 19
use vak_agent::{Agent, AgentConfig, TurnOutcome}; - 20
use vak_llm::stream; - 21
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 22
use vak_llm::{EventStream, LlmError, Provider}; - 23
use vak_permission::PermissionEngine; - 24
use vak_session::types::{FrozenContract, SessionHeader}; - 25
use vak_session::{SessionLog, SessionPath}; - 26
use vak_tools::context::ToolContext; - 27
use vak_tools::{Tool, ToolOutput}; - 28
- 29
type Requests = Arc<Mutex<Vec<Vec<String>>>>; - 30
- 31
struct Scripted { - 32
responses: Mutex<VecDeque<AssistantMessage>>, - 33
/// The tool names each request actually declared. - 34
requests: Requests, - 35
} - 36
- 37
#[async_trait] - 38
impl Provider for Scripted { - 39
fn name(&self) -> &str { - 40
"scripted" - 41
} - 42
- 43
async fn stream( - 44
&self, - 45
request: ChatRequest, - 46
_cancel: CancellationToken, - 47
) -> Result<EventStream, LlmError> { - 48
self.requests - 49
.lock() - 50
.unwrap() - 51
.push(request.tools.iter().map(|t| t.name.clone()).collect()); - 52
let next = self.responses.lock().unwrap().pop_front(); - 53
let (mut sink, rx) = stream::channel(64); - 54
match next { - 55
Some(m) => { - 56
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 57
sink.close_message(m).await; - 58
} - 59
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 60
} - 61
Ok(rx) - 62
} - 63
} - 64
- 65
/// Stand-in for `vak_core::presentation_tools::EmitCardTool` — returns the - 66
/// same `{"semantic_type","payload"}` envelope shape the real tool wraps - 67
/// its arguments in, without pulling in the vak-core dependency. - 68
struct FakeEmitChartCard; - 69
- 70
#[async_trait] - 71
impl Tool for FakeEmitChartCard { - 72
fn name(&self) -> &str { - 73
"emit_chart_card" - 74
} - 75
- 76
fn description(&self) -> &str { - 77
"test stand-in" - 78
} - 79
- 80
fn schema(&self) -> serde_json::Value { - 81
serde_json::json!({"type": "object"}) - 82
} - 83
- 84
fn presents_cards(&self) -> bool { - 85
true - 86
} - 87
- 88
async fn execute(&self, args: &serde_json::Value, _ctx: &ToolContext) -> ToolOutput { - 89
let envelope = serde_json::json!({ - 90
"semantic_type": "chart", - 91
"payload": args.get("payload").cloned().unwrap_or(serde_json::json!({})), - 92
}); - 93
ToolOutput::ok(envelope.to_string()) - 94
} - 95
} - 96
- 97
fn text_msg(t: &str) -> AssistantMessage { - 98
AssistantMessage { - 99
content: vec![ContentBlock::text(t)], - 100
stop_reason: StopReason::EndTurn, - 101
usage: Usage { - 102
input_tokens: 1, - 103
output_tokens: 1, - 104
..Default::default() - 105
}, - 106
model: "test-model".into(), - 107
response_id: None, - 108
} - 109
} - 110
- 111
fn tool_call_msg(id: &str, name: &str, input: serde_json::Value) -> AssistantMessage { - 112
AssistantMessage { - 113
content: vec![ContentBlock::ToolUse { - 114
id: id.into(), - 115
name: name.into(), - 116
input, - 117
}], - 118
stop_reason: StopReason::ToolUse, - 119
usage: Usage { - 120
input_tokens: 1, - 121
output_tokens: 1, - 122
..Default::default() - 123
}, - 124
model: "test-model".into(), - 125
response_id: None, - 126
} - 127
} - 128
- 129
async fn build_agent( - 130
dir: &tempfile::TempDir, - 131
session_id: &str, - 132
responses: Vec<AssistantMessage>, - 133
with_check: bool, - 134
) -> (Agent, Requests) { - 135
let header = SessionHeader { - 136
agent: None, - 137
session_id: session_id.into(), - 138
created_at: chrono::Utc::now(), - 139
cwd: dir.path().to_path_buf(), - 140
parent_session_id: None, - 141
contract_id: None, - 142
work_item_id: None, - 143
conversation: None, - 144
contract: FrozenContract { - 145
app_version: "0".into(), - 146
provider: "scripted".into(), - 147
model: "test-model".into(), - 148
route_ladder: Vec::new(), - 149
route_objective: String::new(), - 150
route_annotations: Vec::new(), - 151
system_prompt: "sys".into(), - 152
permission_mode: "full-access".into(), - 153
capabilities: Vec::new(), - 154
prompt_layers: Vec::new(), - 155
}, - 156
}; - 157
let home = dir.path().join("home"); - 158
std::fs::create_dir_all(&home).unwrap(); - 159
let log = SessionLog::create( - 160
SessionPath::new_session_file(&home, dir.path(), session_id), - 161
header, - 162
) - 163
.unwrap(); - 164
- 165
let requests = Requests::default(); - 166
let agent = Agent::new( - 167
Arc::new(Scripted { - 168
responses: Mutex::new(VecDeque::from(responses)), - 169
requests: requests.clone(), - 170
}), - 171
log, - 172
{ - 173
let mut cfg = AgentConfig::new("sys"); - 174
cfg.model = "test-model".into(); - 175
cfg.mode = vak_permission::Mode::FullAccess; - 176
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 177
cfg.tools = vec![Arc::new(FakeEmitChartCard)]; - 178
// As in production: a card tool the request did not predict is - 179
// deferred, so it is not declared until something loads it. - 180
cfg.tool_definitions = Some(vec![ - 181
vak_llm::ToolDefinition::new( - 182
"emit_chart_card", - 183
"test stand-in", - 184
serde_json::json!({"type": "object"}), - 185
) - 186
.deferred(), - 187
]); - 188
if with_check { - 189
cfg.presentation_check = Some(Arc::new(|text, offered| { - 190
(text.contains("TABLE") && offered.iter().any(|t| t == "emit_chart_card")).then( - 191
|| vak_agent::PresentationNudge { - 192
tool: "emit_chart_card".into(), - 193
text: - 194
"[presentation-check]: this reads as a card; call emit_chart_card." - 195
.into(), - 196
}, - 197
) - 198
})); - 199
} - 200
cfg - 201
}, - 202
); - 203
(agent, requests) - 204
} - 205
- 206
// Raw ledger, not the model-visible projection: these tests are about the - 207
// repair loop's mechanics, which a closed turn's full record deliberately - 208
// no longer preserves (docs/design/68-context-engine.md §10) — a rejected - 209
// draft and a mid-turn nudge are dropped once the turn closes. - 210
fn assistant_texts(agent: &Agent) -> Vec<String> { - 211
futures::executor::block_on(async { - 212
agent - 213
.session - 214
.lock() - 215
.await - 216
.message_chain() - 217
.iter() - 218
.filter(|(_, m)| m.role == vak_llm::types::Role::Assistant) - 219
.flat_map(|(_, m)| m.content.iter()) - 220
.filter_map(|b| match b { - 221
ContentBlock::Text { text } => Some(text.clone()), - 222
_ => None, - 223
}) - 224
.collect() - 225
}) - 226
} - 227
- 228
fn user_texts(agent: &Agent) -> Vec<String> { - 229
futures::executor::block_on(async { - 230
agent - 231
.session - 232
.lock() - 233
.await - 234
.message_chain() - 235
.iter() - 236
.filter(|(_, m)| m.role == vak_llm::types::Role::User) - 237
.flat_map(|(_, m)| m.content.iter()) - 238
.filter_map(|b| match b { - 239
ContentBlock::Text { text } => Some(text.clone()), - 240
_ => None, - 241
}) - 242
.collect() - 243
}) - 244
} - 245
- 246
const PROSE: &str = "Weekly moves as a TABLE:\n\n| Index | Change |\n|---|---|\n| Nifty | -0.22% |"; - 247
const NARRATION: &str = "The chart is shown above."; - 248
const CHART_INPUT: fn() -> serde_json::Value = || serde_json::json!({"semantic_type": "chart", "payload": {"chart_type": "line", "series": []}}); - 249
- 250
async fn run(agent: &mut Agent) -> TurnOutcome { - 251
agent - 252
.run( - 253
"how did the market do", - 254
&Default::default(), - 255
CancellationToken::new(), - 256
mpsc::channel(64).0, - 257
) - 258
.await - 259
} - 260
- 261
#[tokio::test] - 262
async fn prose_that_reads_as_a_card_gets_one_nudge_and_the_model_can_then_emit_it() { - 263
let dir = tempdir().unwrap(); - 264
let (mut agent, requests) = build_agent( - 265
&dir, - 266
"pc-nudge", - 267
vec![ - 268
text_msg(PROSE), - 269
tool_call_msg("c1", "emit_chart_card", CHART_INPUT()), - 270
text_msg(NARRATION), - 271
], - 272
true, - 273
) - 274
.await; - 275
assert!(matches!( - 276
run(&mut agent).await, - 277
TurnOutcome::Completed { .. } - 278
)); - 279
let users = user_texts(&agent); - 280
assert_eq!( - 281
users - 282
.iter() - 283
.filter(|t| t.contains("[presentation-check]")) - 284
.count(), - 285
1, - 286
"{users:?}" - 287
); - 288
assert!(assistant_texts(&agent).iter().any(|t| t == NARRATION)); - 289
let requests = requests.lock().unwrap(); - 290
assert!( - 291
!requests[0].contains(&"emit_chart_card".to_string()), - 292
"the deferred card tool is not declared before the nudge" - 293
); - 294
assert!( - 295
requests[1].contains(&"emit_chart_card".to_string()), - 296
"the nudge loads the card tool it asks for, on a non-Anthropic leg" - 297
); - 298
} - 299
- 300
#[tokio::test] - 301
async fn the_nudge_is_one_shot_and_the_model_may_decline_by_resending() { - 302
let dir = tempdir().unwrap(); - 303
let (mut agent, _) = build_agent( - 304
&dir, - 305
"pc-once", - 306
vec![text_msg(PROSE), text_msg(PROSE)], - 307
true, - 308
) - 309
.await; - 310
assert!(matches!( - 311
run(&mut agent).await, - 312
TurnOutcome::Completed { .. } - 313
)); - 314
let users = user_texts(&agent); - 315
assert_eq!( - 316
users - 317
.iter() - 318
.filter(|t| t.contains("[presentation-check]")) - 319
.count(), - 320
1, - 321
"{users:?}" - 322
); - 323
assert_eq!( - 324
assistant_texts(&agent) - 325
.iter() - 326
.filter(|t| *t == PROSE) - 327
.count(), - 328
2 - 329
); - 330
} - 331
- 332
#[tokio::test] - 333
async fn no_nudge_when_a_card_was_already_emitted_this_run() { - 334
let dir = tempdir().unwrap(); - 335
let (mut agent, _) = build_agent( - 336
&dir, - 337
"pc-emitted", - 338
vec![ - 339
tool_call_msg("c1", "emit_chart_card", CHART_INPUT()), - 340
text_msg(PROSE), - 341
], - 342
true, - 343
) - 344
.await; - 345
assert!(matches!( - 346
run(&mut agent).await, - 347
TurnOutcome::Completed { .. } - 348
)); - 349
assert!( - 350
!user_texts(&agent) - 351
.iter() - 352
.any(|t| t.contains("[presentation-check]")) - 353
); - 354
} - 355
- 356
#[tokio::test] - 357
async fn no_nudge_when_the_answer_already_carries_an_inline_card() { - 358
let dir = tempdir().unwrap(); - 359
let with_fence = format!( - 360
"{PROSE}\n\n```vak\n{{\"semantic_type\":\"metric\",\"payload\":{{\"label\":\"x\",\"value\":1}}}}\n```" - 361
); - 362
let (mut agent, _) = build_agent(&dir, "pc-fence", vec![text_msg(&with_fence)], true).await; - 363
assert!(matches!( - 364
run(&mut agent).await, - 365
TurnOutcome::Completed { .. } - 366
)); - 367
assert!( - 368
!user_texts(&agent) - 369
.iter() - 370
.any(|t| t.contains("[presentation-check]")) - 371
); - 372
} - 373
- 374
#[tokio::test] - 375
async fn with_no_check_configured_the_loop_is_untouched() { - 376
let dir = tempdir().unwrap(); - 377
let (mut agent, _) = build_agent(&dir, "pc-off", vec![text_msg(PROSE)], false).await; - 378
assert!(matches!( - 379
run(&mut agent).await, - 380
TurnOutcome::Completed { .. } - 381
)); - 382
assert!( - 383
!user_texts(&agent) - 384
.iter() - 385
.any(|t| t.contains("[presentation-check]")) - 386
); - 387
} - 388
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.