- 2001
result_preview.unwrap_or_default(), - 2002
), - 2003
}), - 2004
AgentEvent::Sandbox(vak_tools::SandboxEvent::ArtifactGenerated { - 2005
execution_id, - 2006
path, - 2007
mime_type, - 2008
size_bytes, - 2009
}) => Some(OutputStreamEvent::ItemStarted { - 2010
item: OutputItem { - 2011
id: format!("artifact-{execution_id}-{path}"), - 2012
timestamp: now, - 2013
turn_id: format!("sandbox-{execution_id}"), - 2014
role: OutputRole::Tool, - 2015
kind: OutputKind::Artifact, - 2016
status: OutputStatus::Succeeded, - 2017
outcome: None, - 2018
content: OutputContent::Artifact { - 2019
artifact: ArtifactRef { - 2020
name: std::path::Path::new(&path) - 2021
.file_name() - 2022
.and_then(|n| n.to_str()) - 2023
.unwrap_or(&path) - 2024
.to_string(), - 2025
path: Some(path.clone()), - 2026
media_type: Some(mime_type.clone()), - 2027
description: None, - 2028
size_bytes: Some(size_bytes), - 2029
status: None, - 2030
}, - 2031
}, - 2032
provenance: Some(OutputProvenance { - 2033
session_id: Some(session_id.into()), - 2034
entry_id: None, - 2035
tool_call_id: Some(execution_id.clone()), - 2036
source: Some("sandbox_artifact".into()), - 2037
presentation_id: None, - 2038
}), - 2039
// A review action is added after settlement only when an - 2040
// ExecutionStarted receipt proves a reviewable scratch root. - 2041
actions: sandbox_artifact_actions(&execution_id, &path, false), - 2042
fallback_text: format!("Generated artifact: {path}"), - 2043
}, - 2044
}), - 2045
AgentEvent::RetryScheduled { - 2046
attempt, - 2047
delay_ms, - 2048
reason, - 2049
} => Some(OutputStreamEvent::ItemStarted { - 2050
item: live_item( - 2051
session_id, - 2052
format!("retry-{attempt}"), - 2053
now, - 2054
OutputRole::System, - 2055
OutputKind::Retry, - 2056
OutputStatus::Running, - 2057
OutputContent::Retry { - 2058
attempt, - 2059
delay_ms, - 2060
reason: reason.clone(), - 2061
}, - 2062
reason, - 2063
), - 2064
}), - 2065
AgentEvent::RouteFallback { - 2066
to_provider, - 2067
to_model, - 2068
} => Some(OutputStreamEvent::ItemStarted { - 2069
item: live_item( - 2070
session_id, - 2071
format!("route-{to_provider}-{to_model}"), - 2072
now, - 2073
OutputRole::System, - 2074
OutputKind::Information, - 2075
OutputStatus::Running, - 2076
OutputContent::Information { - 2077
label: "Route fallback".into(), - 2078
detail: Some(format!("{to_provider} · {to_model}")), - 2079
}, - 2080
format!("Continuing with {to_provider} · {to_model}"), - 2081
), - 2082
}), - 2083
AgentEvent::WorkerStarted { label } => Some(OutputStreamEvent::ItemStarted { - 2084
item: live_item( - 2085
session_id, - 2086
format!("worker-{label}"), - 2087
now, - 2088
OutputRole::Worker, - 2089
OutputKind::Progress, - 2090
OutputStatus::Running, - 2091
OutputContent::Progress { - 2092
label: label.clone(), - 2093
detail: Some("Worker started".into()), - 2094
percent: None, - 2095
}, - 2096
format!("{label} started"), - 2097
), - 2098
}), - 2099
AgentEvent::WorkerFinished { - 2100
label, - 2101
is_error, - 2102
elapsed_ms, - 2103
} => Some(OutputStreamEvent::ItemReplaced { - 2104
item: live_item( - 2105
session_id, - 2106
format!("worker-{label}"), - 2107
now, - 2108
OutputRole::Worker, - 2109
if is_error { - 2110
OutputKind::Error - 2111
} else { - 2112
OutputKind::Progress - 2113
}, - 2114
if is_error { - 2115
OutputStatus::Failed - 2116
} else { - 2117
OutputStatus::Succeeded - 2118
}, - 2119
if is_error { - 2120
OutputContent::Error { - 2121
message: format!("{label} failed after {elapsed_ms} ms"), - 2122
source: Some("worker".into()), - 2123
retryable: false, - 2124
} - 2125
} else { - 2126
OutputContent::Progress { - 2127
label: label.clone(), - 2128
detail: Some(format!("Completed in {elapsed_ms} ms")), - 2129
percent: Some(100), - 2130
} - 2131
}, - 2132
format!("{label} finished in {elapsed_ms} ms"), - 2133
), - 2134
}), - 2135
// The managed-work projection is not rendered from the presentation - 2136
// timeline (the work panel reads `GET /sessions/:id/work` instead); - 2137
// it previously leaked its full `serde_json::to_string` dump into an - 2138
// `Information` item's `detail`/`fallback_text` (docs/audits Finding - 2139
// 3 -- a raw JSON blob is exactly what a client must never receive). - 2140
AgentEvent::WorkState { .. } => None, - 2141
AgentEvent::ApprovalRequested { - 2142
id, - 2143
tool, - 2144
args_json, - 2145
reason, - 2146
} => Some(OutputStreamEvent::ItemStarted { - 2147
item: OutputItem { - 2148
id: id.clone(), - 2149
timestamp: now, - 2150
turn_id: "live".into(), - 2151
role: OutputRole::System, - 2152
kind: OutputKind::Approval, - 2153
status: OutputStatus::Pending, - 2154
outcome: None, - 2155
content: OutputContent::Approval { - 2156
request_id: id.clone(), - 2157
tool, - 2158
args_json, - 2159
reason: reason.clone(), - 2160
expires_at: None, - 2161
}, - 2162
provenance: Some(OutputProvenance { - 2163
session_id: Some(session_id.into()), - 2164
entry_id: None, - 2165
tool_call_id: None, - 2166
source: Some("live_event".into()), - 2167
presentation_id: None, - 2168
}), - 2169
actions: vec![ - 2170
DeliveryAction { - 2171
id: format!("approve-{id}"), - 2172
label: "Allow once".into(), - 2173
verb: "resolve_approval".into(), - 2174
data: BTreeMap::from([ - 2175
("request_id".into(), id.clone()), - 2176
("verdict".into(), "allow".into()), - 2177
]), - 2178
}, - 2179
DeliveryAction { - 2180
id: format!("deny-{id}"), - 2181
label: "Deny".into(), - 2182
verb: "resolve_approval".into(), - 2183
data: BTreeMap::from([ - 2184
("request_id".into(), id.clone()), - 2185
("verdict".into(), "deny".into()), - 2186
]), - 2187
}, - 2188
], - 2189
fallback_text: reason, - 2190
}, - 2191
}), - 2192
AgentEvent::RunFinished { is_error, .. } => Some(OutputStreamEvent::ItemCompleted { - 2193
item_id: "live-assistant".into(), - 2194
status: if is_error { - 2195
OutputStatus::Failed - 2196
} else { - 2197
OutputStatus::Succeeded - 2198
}, - 2199
}), - 2200
_ => None, - 2201
} - 2202
} - 2203
- 2204
pub(crate) fn project_frame( - 2205
timeline: &mut OutputTimeline, - 2206
framed: crate::events::SeqEvent, - 2207
) -> Option<vak_delivery::OutputStreamFrame> { - 2208
let sequence = framed.seq; - 2209
let mut event = live_event(&timeline.session_id, framed.event)?; - 2210
let active = timeline - 2211
.items - 2212
.iter() - 2213
.rev() - 2214
.find(|item| item.id.starts_with("live-assistant-")) - 2215
.map(|item| (item.id.clone(), item.turn_id.clone())); - 2216
match &mut event { - 2217
OutputStreamEvent::ItemStarted { item } | OutputStreamEvent::ItemReplaced { item } => { - 2218
if item.id == "live-assistant" { - 2219
item.id = format!("live-assistant-{sequence}"); - 2220
item.turn_id = format!("live-turn-{sequence}"); - 2221
} else if item.turn_id == "live" - 2222
&& let Some((_, turn)) = &active - 2223
{ - 2224
item.turn_id = turn.clone(); - 2225
} - 2226
} - 2227
OutputStreamEvent::TextDelta { item_id, .. } - 2228
| OutputStreamEvent::ItemCompleted { item_id, .. } => { - 2229
if let Some((id, _)) = &active { - 2230
*item_id = id.clone(); - 2231
} - 2232
} - 2233
OutputStreamEvent::Snapshot { .. } => {} - 2234
} - 2235
apply_stream_event(timeline, event.clone()); - 2236
timeline.cursor = Some(format!("live:{sequence}")); - 2237
// No `timeline.clone()` here: a live frame carries only its own small - 2238
// delta. The per-handle projector in `register_handle` applies every - 2239
// event through this function whether or not a client is subscribed, so - 2240
// a snapshot built here would be an unconditional full-timeline clone - 2241
// per event regardless of demand (docs/audits Finding 2). - 2242
Some(vak_delivery::OutputStreamFrame { - 2243
sequence: Some(sequence), - 2244
delta: Some(event), - 2245
snapshot: None, - 2246
}) - 2247
} - 2248
- 2249
/// Rebase a live presentation stream onto the durable projection at a run - 2250
/// boundary. `snapshot` alone is authoritative here — `delta` stays `None` - 2251
/// rather than carrying the same timeline a second time as - 2252
/// `OutputStreamEvent::Snapshot` (docs/audits Finding 2: a settlement frame - 2253
/// previously measured up to 9.3 MB by sending it twice). - 2254
pub(crate) fn settled_frame( - 2255
sequence: u64, - 2256
timeline: &OutputTimeline, - 2257
) -> vak_delivery::OutputStreamFrame { - 2258
let mut snapshot = timeline.clone(); - 2259
// The handle's background projector receives the same RunFinished event - 2260
// and may win the mutex race, changing only the cursor back to `live:*`. - 2261
// A settlement frame is an authoritative replacement regardless of that - 2262
// scheduling order, so give it an explicitly non-live cursor. - 2263
if snapshot - 2264
.cursor - 2265
.as_deref() - 2266
.is_some_and(|cursor| cursor.starts_with("live:")) - 2267
{ - 2268
snapshot.cursor = Some(format!("settled:{sequence}")); - 2269
} - 2270
vak_delivery::OutputStreamFrame { - 2271
sequence: Some(sequence), - 2272
delta: None, - 2273
snapshot: Some(snapshot), - 2274
} - 2275
} - 2276
- 2277
pub(crate) fn apply_stream_event(timeline: &mut OutputTimeline, event: OutputStreamEvent) { - 2278
match event { - 2279
OutputStreamEvent::Snapshot { timeline: snapshot } => *timeline = snapshot, - 2280
OutputStreamEvent::ItemStarted { item } | OutputStreamEvent::ItemReplaced { item } => { - 2281
if let Some(existing) = timeline - 2282
.items - 2283
.iter_mut() - 2284
.find(|candidate| candidate.id == item.id) - 2285
{ - 2286
*existing = item; - 2287
} else { - 2288
timeline.items.push(item); - 2289
} - 2290
} - 2291
OutputStreamEvent::TextDelta { item_id, text, .. } => { - 2292
if let Some(item) = timeline - 2293
.items - 2294
.iter_mut() - 2295
.find(|candidate| candidate.id == item_id) - 2296
{ - 2297
item.fallback_text = text.clone(); - 2298
item.content = OutputContent::Document { - 2299
document: compile_markdown(text), - 2300
}; - 2301
} - 2302
} - 2303
OutputStreamEvent::ItemCompleted { item_id, status } => { - 2304
if let Some(item) = timeline - 2305
.items - 2306
.iter_mut() - 2307
.find(|candidate| candidate.id == item_id) - 2308
{ - 2309
item.status = status; - 2310
if matches!(item.content, OutputContent::Document { .. }) { - 2311
item.content = OutputContent::Document { - 2312
document: compile_markdown(item.fallback_text.clone()), - 2313
}; - 2314
} - 2315
} - 2316
} - 2317
} - 2318
} - 2319
- 2320
#[allow(clippy::too_many_arguments)] - 2321
fn live_item( - 2322
session_id: &str, - 2323
id: String, - 2324
timestamp: String, - 2325
role: OutputRole, - 2326
kind: OutputKind, - 2327
status: OutputStatus, - 2328
content: OutputContent, - 2329
fallback_text: String, - 2330
) -> OutputItem { - 2331
OutputItem { - 2332
id, - 2333
timestamp, - 2334
turn_id: "live".into(), - 2335
role, - 2336
kind, - 2337
status, - 2338
outcome: None, - 2339
content, - 2340
provenance: Some(OutputProvenance { - 2341
session_id: Some(session_id.into()), - 2342
entry_id: None, - 2343
tool_call_id: None, - 2344
source: Some("live_event".into()), - 2345
presentation_id: None, - 2346
}), - 2347
actions: Vec::new(), - 2348
fallback_text, - 2349
} - 2350
} - 2351
- 2352
#[cfg(test)] - 2353
#[allow(clippy::expect_used, clippy::panic)] - 2354
mod tests { - 2355
use super::activity_item; - 2356
use super::{artifact_from_tool, snapshot}; - 2357
use std::collections::{BTreeMap, HashMap}; - 2358
use std::path::PathBuf; - 2359
use vak_delivery::{ - 2360
ArtifactStatus, OutputContent, OutputItem, OutputKind, OutputProvenance, OutputRole, - 2361
OutputStatus, OutputTimeline, ResultOutcome, - 2362
}; - 2363
use vak_llm::{ContentBlock, Message, Role}; - 2364
use vak_session::{ - 2365
ActivityKind, ActivityRecord, ActivityStatus, FrozenContract, MessageRecord, SessionHeader, - 2366
SessionLog, - 2367
}; - 2368
- 2369
#[test] - 2370
fn settled_frame_rebases_snapshot_to_durable_history_and_sends_no_delta() { - 2371
let mut durable = OutputTimeline::empty("session-1"); - 2372
durable.cursor = Some("live:41".into()); - 2373
let frame = super::settled_frame(42, &durable); - 2374
assert_eq!(frame.sequence, Some(42)); - 2375
let snapshot = frame - 2376
.snapshot - 2377
.expect("settlement always carries a snapshot"); - 2378
assert_eq!(snapshot.cursor.as_deref(), Some("settled:42")); - 2379
// A settlement frame carries the timeline exactly once: `snapshot` - 2380
// is the sole authority and `delta` stays `None` rather than - 2381
// repeating it as `OutputStreamEvent::Snapshot` (docs/audits Finding - 2382
// 2 -- doubling this previously measured up to 9.3 MB per answer). - 2383
assert!(frame.delta.is_none()); - 2384
} - 2385
- 2386
#[test] - 2387
fn project_frame_carries_only_a_delta_never_a_snapshot() { - 2388
let mut timeline = OutputTimeline::empty("session-1"); - 2389
let framed = crate::events::SeqEvent { - 2390
seq: 7, - 2391
event: vak_agent::AgentEvent::TurnStart { turn: 0 }, - 2392
}; - 2393
let frame = super::project_frame(&mut timeline, framed).expect("TurnStart projects"); - 2394
assert_eq!(frame.sequence, Some(7)); - 2395
assert!(frame.delta.is_some()); - 2396
assert!(frame.snapshot.is_none()); - 2397
} - 2398
- 2399
/// Writes the `Presentation` entry a real turn would have written at - 2400
/// card validation (docs/design/68-context-engine.md §10), so these - 2401
/// fixture ledgers exercise the same projection path production does: - 2402
/// reading the entry, never rebuilding the card from `tool_use.input`. - 2403
fn append_presentation_for_call( - 2404
log: &mut SessionLog, - 2405
tool: &str, - 2406
tool_use_id: &str, - 2407
args: &serde_json::Value, - 2408
) { - 2409
let skills = vak_delivery::built_in_skill_registry(); - 2410
let info = vak_core::presentation_tools::presentation_info(tool, args, &skills) - 2411
.expect("fixture call must validate"); - 2412
let turn_id = log.latest_directive_entry_id().unwrap_or_default(); - 2413
let payload_digest = vak_session::types::payload_digest(&info.payload); - 2414
log.append_presentation(vak_session::types::PresentationRecord { - 2415
turn_id, - 2416
source: vak_session::types::PresentationSource::ToolCall { - 2417
tool_use_id: tool_use_id.into(), - 2418
}, - 2419
semantic_type: info.semantic_type, - 2420
skill_id: info.skill_id, - 2421
skill_version: info.skill_version, - 2422
schema_version: info.schema_version, - 2423
payload: info.payload, - 2424
payload_digest, - 2425
derived_from: Vec::new(), - 2426
title: info.title, - 2427
identity_digest: info.identity_digest, - 2428
}) - 2429
.expect("append presentation"); - 2430
} - 2431
- 2432
#[test] - 2433
fn runtime_bookkeeping_never_reaches_the_client_snapshot() { - 2434
let dir = tempfile::tempdir().expect("tempdir"); - 2435
let mut log = SessionLog::create( - 2436
dir.path().join("bookkeeping.jsonl"), - 2437
SessionHeader { - 2438
agent: None, - 2439
session_id: "bookkeeping".into(), - 2440
created_at: chrono::Utc::now(), - 2441
cwd: PathBuf::from("/tmp/project"), - 2442
parent_session_id: None, - 2443
contract_id: None, - 2444
work_item_id: None, - 2445
conversation: None, - 2446
contract: FrozenContract { - 2447
app_version: "test".into(), - 2448
provider: "test".into(), - 2449
model: "test".into(), - 2450
route_ladder: Vec::new(), - 2451
route_objective: String::new(), - 2452
route_annotations: Vec::new(), - 2453
system_prompt: String::new(), - 2454
permission_mode: "read-only".into(), - 2455
capabilities: Vec::new(), - 2456
prompt_layers: Vec::new(), - 2457
}, - 2458
}, - 2459
) - 2460
.expect("create session"); - 2461
let activity = |id: &str, kind: ActivityKind, label: &str| ActivityRecord { - 2462
activity_id: id.into(), - 2463
turn: None, - 2464
kind, - 2465
status: ActivityStatus::Succeeded, - 2466
label: label.into(), - 2467
detail: None, - 2468
data: BTreeMap::new(), - 2469
}; - 2470
for record in [ - 2471
activity("admission-r1", ActivityKind::Run, "Request accepted"), - 2472
activity( - 2473
"probe-1", - 2474
ActivityKind::CapacityProbe, - 2475
"Capacity profile bound", - 2476
), - 2477
activity( - 2478
"feedback-1", - 2479
ActivityKind::CapacityFeedback, - 2480
"Capacity profile updated", - 2481
), - 2482
activity("diag-1", ActivityKind::Diagnostic, "prefix-changed"), - 2483
activity("retry-1", ActivityKind::Retry, "Retry attempt 1"), - 2484
activity("route-1", ActivityKind::RouteFallback, "Route fallback"), - 2485
] { - 2486
log.append_activity(record).expect("activity"); - 2487
} - 2488
log.append_goal_update(vak_intent::GoalUpdate { - 2489
revision: 1, - 2490
relation: vak_intent::GoalRelation::New, - 2491
request: "say hi".into(), - 2492
supersedes_revision: None, - 2493
explicit: false, - 2494
}) - 2495
.expect("goal update"); - 2496
log.append_message(MessageRecord { - 2497
message: Message::user_text("say hi"), - 2498
meta: None, - 2499
}) - 2500
.expect("directive"); - 2501
log.append_message(MessageRecord { - 2502
message: Message::assistant(vec![ContentBlock::text("Hi.")]), - 2503
meta: None, - 2504
}) - 2505
.expect("answer"); - 2506
- 2507
let timeline = snapshot("bookkeeping", &log); - 2508
let leaked: Vec<&str> = timeline - 2509
.items - 2510
.iter() - 2511
.filter(|item| item.role == OutputRole::System) - 2512
.map(|item| item.fallback_text.as_str()) - 2513
.collect(); - 2514
assert!( - 2515
leaked.is_empty(), - 2516
"internal items reached the client: {leaked:?}" - 2517
); - 2518
assert!( - 2519
timeline - 2520
.items - 2521
.iter() - 2522
.any(|item| item.fallback_text.contains("Hi.")), - 2523
"the answer itself must still be projected" - 2524
); - 2525
} - 2526
- 2527
#[test] - 2528
fn a_workers_cards_project_as_cards_of_the_task_call() { - 2529
let dir = tempfile::tempdir().expect("tempdir"); - 2530
let mut log = SessionLog::create( - 2531
dir.path().join("delegated.jsonl"), - 2532
SessionHeader { - 2533
agent: None, - 2534
session_id: "delegated".into(), - 2535
created_at: chrono::Utc::now(), - 2536
cwd: PathBuf::from("/tmp/project"), - 2537
parent_session_id: None, - 2538
contract_id: None, - 2539
work_item_id: None, - 2540
conversation: None, - 2541
contract: FrozenContract { - 2542
app_version: "test".into(), - 2543
provider: "test".into(), - 2544
model: "test".into(), - 2545
route_ladder: Vec::new(), - 2546
route_objective: String::new(), - 2547
route_annotations: Vec::new(), - 2548
system_prompt: String::new(), - 2549
permission_mode: "read-only".into(), - 2550
capabilities: Vec::new(), - 2551
prompt_layers: Vec::new(), - 2552
}, - 2553
}, - 2554
) - 2555
.expect("create session"); - 2556
log.append_message(MessageRecord { - 2557
message: Message::user_text("chart it and summarise it"), - 2558
meta: None, - 2559
}) - 2560
.expect("directive"); - 2561
log.append_message(MessageRecord { - 2562
message: Message::assistant(vec![ContentBlock::ToolUse { - 2563
id: "task-1".into(), - 2564
name: "task".into(), - 2565
input: serde_json::json!({"prompt": "chart it"}), - 2566
}]), - 2567
meta: None, - 2568
}) - 2569
.expect("task call"); - 2570
let skills = vak_delivery::built_in_skill_registry(); - 2571
let turn_id = log.latest_directive_entry_id().unwrap_or_default(); - 2572
let mut ids = Vec::new(); - 2573
for summary in ["revenue", "cost"] { - 2574
let info = vak_core::presentation_tools::presentation_info( - 2575
"emit_chart_card", - 2576
&serde_json::json!({"semantic_type":"chart","payload":{"chart_type":"line","series":[],"accessible_summary":summary}}), - 2577
&skills, - 2578
) - 2579
.expect("fixture card validates"); - 2580
let entry = log - 2581
.append_presentation(vak_session::types::PresentationRecord { - 2582
turn_id: turn_id.clone(), - 2583
source: vak_session::types::PresentationSource::Delegated { - 2584
tool_use_id: "task-1".into(), - 2585
worker_session_id: "child-1".into(), - 2586
}, - 2587
semantic_type: info.semantic_type, - 2588
skill_id: info.skill_id, - 2589
skill_version: info.skill_version, - 2590
schema_version: info.schema_version, - 2591
payload_digest: vak_session::types::payload_digest(&info.payload), - 2592
payload: info.payload, - 2593
derived_from: vec!["task-1".into()], - 2594
title: info.title, - 2595
identity_digest: info.identity_digest, - 2596
}) - 2597
.expect("delegated presentation"); - 2598
ids.push(entry.id); - 2599
} - 2600
log.append_message(MessageRecord { - 2601
message: Message { - 2602
role: Role::User, - 2603
content: vec![ContentBlock::ToolResult { - 2604
tool_use_id: "task-1".into(), - 2605
content: "Revenue rose and cost held.".into(), - 2606
is_error: false, - 2607
}], - 2608
}, - 2609
meta: None, - 2610
}) - 2611
.expect("task result"); - 2612
log.append_message(MessageRecord { - 2613
message: Message::assistant(vec![ContentBlock::text("Both charts are above.")]), - 2614
meta: None, - 2615
}) - 2616
.expect("answer"); - 2617
- 2618
let timeline = snapshot("delegated", &log); - 2619
let cards: Vec<&OutputItem> = timeline - 2620
.items - 2621
.iter() - 2622
.filter(|item| item.kind == OutputKind::Card) - 2623
.collect(); - 2624
assert_eq!(cards.len(), 2, "both of the worker's cards are shown"); - 2625
for (card, id) in cards.iter().zip(&ids) { - 2626
let provenance = card.provenance.as_ref().expect("provenance"); - 2627
assert_eq!(provenance.tool_call_id.as_deref(), Some("task-1")); - 2628
assert_eq!(provenance.presentation_id.as_deref(), Some(id.as_str())); - 2629
} - 2630
} - 2631
- 2632
#[test] - 2633
fn write_tools_project_artifacts() { - 2634
let artifact = artifact_from_tool( - 2635
"write", - 2636
&serde_json::json!({ "path": "/tmp/report.md", "content": "x" }), - 2637
) - 2638
.expect("write should produce an artifact"); - 2639
assert_eq!(artifact.name, "report.md"); - 2640
assert_eq!(artifact.media_type.as_deref(), Some("text/markdown")); - 2641
- 2642
let html = artifact_from_tool( - 2643
"write_file", - 2644
&serde_json::json!({ "path": "welcome.html", "content": "<h1>Hello</h1>" }), - 2645
) - 2646
.expect("write alias should produce an artifact"); - 2647
assert_eq!(html.media_type.as_deref(), Some("text/html")); - 2648
} - 2649
- 2650
#[test] - 2651
fn sandbox_artifacts_rejoin_their_durable_result_turn() { - 2652
let home = tempfile::tempdir().expect("temporary home"); - 2653
let events = home.path().join("sandbox/executions"); - 2654
std::fs::create_dir_all(&events).expect("execution directory"); - 2655
let scratch = home.path().join(".vak/scratch/call-1"); - 2656
std::fs::create_dir_all(&scratch).expect("scratch directory"); - 2657
let event = vak_tools::SandboxEvent::ArtifactGenerated { - 2658
execution_id: "call-1".into(), - 2659
path: ".vak/scratch/call-1/report.html".into(), - 2660
mime_type: "text/html".into(), - 2661
size_bytes: 42, - 2662
}; - 2663
let started = vak_tools::SandboxEvent::ExecutionStarted { - 2664
execution_id: "call-1".into(), - 2665
owner_session_id: Some("session-1".into()), - 2666
tool: "bash".into(), - 2667
code_preview: "generate report".into(), - 2668
language: "sh".into(), - 2669
scratch_dir: scratch.to_string_lossy().into_owned(), - 2670
}; - 2671
std::fs::write( - 2672
events.join("session-1.jsonl"), - 2673
format!( - 2674
"{}\n{}\n", - 2675
serde_json::to_string(&started).expect("started json"), - 2676
serde_json::to_string(&event).expect("event json") - 2677
), - 2678
) - 2679
.expect("event sidecar"); - 2680
- 2681
let mut timeline = OutputTimeline::empty("session-1"); - 2682
timeline.items.push(OutputItem { - 2683
id: "tool-call-1".into(), - 2684
timestamp: "2026-09-20T10:00:00+00:00".into(), - 2685
turn_id: "turn-7".into(), - 2686
role: OutputRole::Tool, - 2687
kind: OutputKind::Progress, - 2688
status: OutputStatus::Succeeded, - 2689
outcome: None, - 2690
content: OutputContent::Progress { - 2691
label: "bash".into(), - 2692
detail: None, - 2693
percent: None, - 2694
}, - 2695
provenance: Some(OutputProvenance { - 2696
session_id: Some("session-1".into()), - 2697
entry_id: Some("entry-1".into()), - 2698
tool_call_id: Some("call-1".into()), - 2699
source: Some("bash".into()), - 2700
presentation_id: None, - 2701
}), - 2702
actions: Vec::new(), - 2703
fallback_text: String::new(), - 2704
}); - 2705
timeline.items.push(OutputItem { - 2706
id: "result-1".into(), - 2707
timestamp: "2026-09-20T10:00:01+00:00".into(), - 2708
turn_id: "turn-7".into(), - 2709
role: OutputRole::Assistant, - 2710
kind: OutputKind::Outcome, - 2711
status: OutputStatus::Succeeded, - 2712
outcome: Some(ResultOutcome { - 2713
result_id: "result-1".into(), - 2714
status: OutputStatus::Succeeded, - 2715
completion: None, - 2716
evidence_state: None, - 2717
requirement_ids: Vec::new(), - 2718
evidence_receipt_ids: Vec::new(), - 2719
evidence: Vec::new(), - 2720
human_review: None, - 2721
}), - 2722
content: OutputContent::Information { - 2723
label: "Result".into(), - 2724
detail: None, - 2725
}, - 2726
provenance: None, - 2727
actions: Vec::new(), - 2728
fallback_text: "Result".into(), - 2729
}); - 2730
- 2731
super::append_sandbox_artifacts(&mut timeline, home.path(), "session-1"); - 2732
let artifact = timeline - 2733
.items - 2734
.iter() - 2735
.find(|item| item.kind == OutputKind::Artifact) - 2736
.expect("projected artifact"); - 2737
assert_eq!(artifact.turn_id, "turn-7"); - 2738
assert_eq!(artifact.timestamp, "2026-09-20T10:00:00+00:00"); - 2739
assert_eq!( - 2740
artifact - 2741
.outcome - 2742
.as_ref() - 2743
.map(|outcome| outcome.result_id.as_str()), - 2744
Some("result-1") - 2745
); - 2746
assert!(artifact.actions.iter().any(|action| { - 2747
action.verb == "review_draft" - 2748
&& action.data.get("execution_id").map(String::as_str) == Some("call-1") - 2749
})); - 2750
let OutputContent::Artifact { artifact } = &artifact.content else { - 2751
panic!("artifact content"); - 2752
}; - 2753
assert_eq!(artifact.description, None, "no byte count in words"); - 2754
assert_eq!(artifact.size_bytes, Some(42)); - 2755
assert_eq!( - 2756
artifact.status, - 2757
Some(ArtifactStatus::Draft { - 2758
version: 1, - 2759
saved_as: None - 2760
}) - 2761
); - 2762
} - 2763
- 2764
/// A draft's status follows the durable records the way Review reads - 2765
/// them: saved versions count up, an acceptance settles the round, an - 2766
/// undo reopens it, and a version saved later starts a new round. - 2767
#[test] - 2768
fn draft_status_follows_saved_versions_and_acceptance() { - 2769
let home = tempfile::tempdir().expect("temporary home"); - 2770
let events = home.path().join("sandbox/executions"); - 2771
std::fs::create_dir_all(&events).expect("execution directory"); - 2772
let scratch = home.path().join(".vak/scratch/vak/call-1"); - 2773
std::fs::create_dir_all(&scratch).expect("scratch directory"); - 2774
let started = vak_tools::SandboxEvent::ExecutionStarted { - 2775
execution_id: "call-1".into(), - 2776
owner_session_id: Some("session-1".into()), - 2777
tool: "bash".into(), - 2778
code_preview: "write page".into(), - 2779
language: "sh".into(), - 2780
scratch_dir: scratch.to_string_lossy().into_owned(), - 2781
}; - 2782
let generated = vak_tools::SandboxEvent::ArtifactGenerated { - 2783
execution_id: "call-1".into(), - 2784
path: ".vak/scratch/vak/call-1/site/page.html".into(), - 2785
mime_type: "text/html".into(), - 2786
size_bytes: 120, - 2787
}; - 2788
std::fs::write( - 2789
events.join("session-1.jsonl"), - 2790
format!( - 2791
"{}\n{}\n", - 2792
serde_json::to_string(&started).expect("started json"), - 2793
serde_json::to_string(&generated).expect("event json") - 2794
), - 2795
) - 2796
.expect("write events"); - 2797
let candidate = |id: &str, session: &str| { - 2798
serde_json::json!({"kind": "Candidate", "record": { - 2799
"record_id": format!("record-{id}"), "session_id": session, "turn_id": "turn-1", - 2800
"result_id": "result-1", "execution_id": "call-1", "environment_id": "env-1", - 2801
"candidate_digest": "digest", "verified": true, "updated_at": "2026-09-26T00:00:00Z", - 2802
"candidate": {"candidate_id": id, "source_root": "/saved", "destination_root": "/workspace", - 2803
"files": [{"path": "site/page.html", "candidate_hash": "h", "base_hash": null, "bytes": 120}]} - 2804
}}) - 2805
}; - 2806
let promotion = |id: &str| { - 2807
serde_json::json!({"kind": "Promotion", "record": { - 2808
"record_id": format!("promotion-{id}"), "session_id": "session-1", "result_id": "result-1", - 2809
"candidate_digest": "digest", "candidate_id": id, "updated_at": "2026-09-26T00:00:00Z", - 2810
"receipt": {"candidate_id": id, "applied": [], "before_hashes": [], "after_hashes": [], "verification": []} - 2811
}}) - 2812
}; - 2813
let undo = |id: &str| { - 2814
serde_json::json!({"kind": "PromotionUndo", "record": { - 2815
"record_id": format!("undo-{id}"), "session_id": "session-1", "candidate_id": id, - 2816
"updated_at": "2026-09-26T00:00:00Z", - 2817
"receipt": {"candidate_id": id, "restored": [], "verification": []} - 2818
}}) - 2819
}; - 2820
let status_after = |records: &[serde_json::Value]| { - 2821
let text: String = records.iter().map(|record| format!("{record}\n")).collect(); - 2822
std::fs::write(home.path().join("sandbox/records.jsonl"), text).expect("records"); - 2823
let mut timeline = OutputTimeline::empty("session-1"); - 2824
super::append_sandbox_artifacts(&mut timeline, home.path(), "session-1"); - 2825
let item = timeline - 2826
.items - 2827
.iter() - 2828
.find(|item| item.kind == OutputKind::Artifact) - 2829
.expect("projected artifact"); - 2830
let OutputContent::Artifact { artifact } = &item.content else { - 2831
panic!("artifact content"); - 2832
}; - 2833
artifact.status.clone() - 2834
}; - 2835
let saved = |id: &str| { - 2836
Some(vak_delivery::VersionFile { - 2837
version_id: id.into(), - 2838
path: "site/page.html".into(), - 2839
}) - 2840
}; - 2841
- 2842
let mut records = vec![candidate("v1", "session-1"), candidate("x", "session-2")]; - 2843
assert_eq!( - 2844
status_after(&records), - 2845
Some(ArtifactStatus::Draft { - 2846
version: 1, - 2847
saved_as: saved("v1") - 2848
}), - 2849
"another conversation's version does not count" - 2850
); - 2851
records.push(candidate("v2", "session-1")); - 2852
assert_eq!( - 2853
status_after(&records), - 2854
Some(ArtifactStatus::Draft { - 2855
version: 2, - 2856
saved_as: saved("v2") - 2857
}) - 2858
); - 2859
records.push(promotion("v2")); - 2860
assert_eq!( - 2861
status_after(&records), - 2862
Some(ArtifactStatus::Accepted { - 2863
version: 2, - 2864
saved_as: saved("v2") - 2865
}) - 2866
); - 2867
records.push(undo("v2")); - 2868
assert_eq!( - 2869
status_after(&records), - 2870
Some(ArtifactStatus::Draft { - 2871
version: 2, - 2872
saved_as: saved("v2") - 2873
}), - 2874
"undoing the acceptance reopens the draft" - 2875
); - 2876
records.push(promotion("v1")); - 2877
records.push(candidate("v3", "session-1")); - 2878
assert_eq!( - 2879
status_after(&records), - 2880
Some(ArtifactStatus::Draft { - 2881
version: 3, - 2882
saved_as: saved("v3") - 2883
}), - 2884
"a version saved after an acceptance starts a new round" - 2885
); - 2886
- 2887
std::fs::write(home.path().join("sandbox/records.jsonl"), "not json\n").expect("records"); - 2888
let mut timeline = OutputTimeline::empty("session-1"); - 2889
super::append_sandbox_artifacts(&mut timeline, home.path(), "session-1"); - 2890
let OutputContent::Artifact { artifact } = &timeline.items[0].content else { - 2891
panic!("artifact content"); - 2892
}; - 2893
assert_eq!( - 2894
artifact.status, None, - 2895
"unreadable records leave the status unknown" - 2896
); - 2897
} - 2898
- 2899
#[test] - 2900
fn direct_write_artifact_merges_sidecar_without_false_review_action() { - 2901
let home = tempfile::tempdir().expect("temporary home"); - 2902
let events = home.path().join("sandbox/executions"); - 2903
std::fs::create_dir_all(&events).expect("execution directory"); - 2904
let observed = vak_tools::SandboxEvent::ArtifactGenerated { - 2905
execution_id: "write-1".into(), - 2906
path: "page.html".into(), - 2907
mime_type: "text/html".into(), - 2908
size_bytes: 21, - 2909
}; - 2910
std::fs::write( - 2911
events.join("session-1.jsonl"), - 2912
format!( - 2913
"{}\n", - 2914
serde_json::to_string(&observed).expect("event json") - 2915
), - 2916
) - 2917
.expect("event sidecar"); - 2918
let mut timeline = OutputTimeline::empty("session-1"); - 2919
timeline.items.push(OutputItem { - 2920
id: "write-1-artifact".into(), - 2921
timestamp: "2026-09-20T10:00:00+00:00".into(), - 2922
turn_id: "turn-1".into(), - 2923
role: OutputRole::Tool, - 2924
kind: OutputKind::Artifact, - 2925
status: OutputStatus::Succeeded, - 2926
outcome: None, - 2927
content: OutputContent::Artifact { - 2928
artifact: vak_delivery::ArtifactRef { - 2929
name: "page.html".into(), - 2930
path: Some("page.html".into()), - 2931
media_type: Some("text/html".into()), - 2932
description: None, - 2933
size_bytes: None, - 2934
status: Some(ArtifactStatus::InFolder), - 2935
}, - 2936
}, - 2937
provenance: Some(OutputProvenance { - 2938
session_id: Some("session-1".into()), - 2939
entry_id: Some("entry-1".into()), - 2940
tool_call_id: Some("write-1".into()), - 2941
source: Some("write".into()), - 2942
presentation_id: None, - 2943
}), - 2944
actions: Vec::new(), - 2945
fallback_text: "page.html".into(), - 2946
}); - 2947
- 2948
super::append_sandbox_artifacts(&mut timeline, home.path(), "session-1"); - 2949
- 2950
let artifacts = timeline - 2951
.items - 2952
.iter() - 2953
.filter(|item| item.kind == OutputKind::Artifact) - 2954
.collect::<Vec<_>>(); - 2955
assert_eq!(artifacts.len(), 1); - 2956
assert!( - 2957
artifacts[0] - 2958
.actions - 2959
.iter() - 2960
.any(|action| action.verb == "open_artifact") - 2961
); - 2962
assert!( - 2963
!artifacts[0] - 2964
.actions - 2965
.iter() - 2966
.any(|action| action.verb == "review_draft") - 2967
); - 2968
let OutputContent::Artifact { artifact } = &artifacts[0].content else { - 2969
panic!("artifact content"); - 2970
}; - 2971
assert_eq!(artifact.status, Some(ArtifactStatus::InFolder)); - 2972
assert_eq!(artifact.description, None); - 2973
assert_eq!(artifact.size_bytes, Some(21)); - 2974
} - 2975
- 2976
#[test] - 2977
fn outcome_evaluation_exposes_review_actions() { - 2978
let item = activity_item( - 2979
"session-1", - 2980
"activity-1", - 2981
"2026-01-01T00:00:00Z", - 2982
"turn-1", - 2983
&ActivityRecord { - 2984
activity_id: "evaluation-1".into(), - 2985
turn: Some(1), - 2986
kind: ActivityKind::Diagnostic, - 2987
status: ActivityStatus::Succeeded, - 2988
label: "Outcome evaluation".into(), - 2989
detail: Some("partial".into()), - 2990
data: BTreeMap::new(), - 2991
}, - 2992
); - 2993
assert_eq!(item.actions.len(), 3); - 2994
assert!( - 2995
item.actions - 2996
.iter() - 2997
.all(|action| action.verb == "record_outcome_review") - 2998
); - 2999
} - 3000
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.