- 1001
match markup { - 1002
Markup::TelegramHtml => telegram::markdown_to_html(markdown), - 1003
Markup::SlackMrkdwn => slack::markdown_to_mrkdwn(markdown), - 1004
Markup::DiscordMarkdown => discord::markdown_to_discord(markdown), - 1005
Markup::Plain | Markup::Markdown | Markup::Json => markdown.to_string(), - 1006
} - 1007
} - 1008
- 1009
fn render_progress(progress: &ProgressPayload, markup: Markup) -> String { - 1010
let text = format!("{}: {}", progress.label, progress.state); - 1011
if matches!(markup, Markup::TelegramHtml) { - 1012
escape_html(&text) - 1013
} else { - 1014
text - 1015
} - 1016
} - 1017
- 1018
fn render_tool_result( - 1019
result: &ToolResultPayload, - 1020
markup: Markup, - 1021
project_structured: bool, - 1022
skills: &SkillRegistry, - 1023
) -> String { - 1024
let prefix = if result.is_error { - 1025
"Tool error" - 1026
} else { - 1027
"Tool result" - 1028
}; - 1029
let formatted = format!("{prefix} ({}\n\n{}", result.tool, result.output); - 1030
let text = if project_structured { - 1031
project_structured_fences_with(&formatted, skills) - 1032
} else { - 1033
formatted - 1034
}; - 1035
if matches!(markup, Markup::TelegramHtml) { - 1036
escape_html(&text) - 1037
} else { - 1038
text - 1039
} - 1040
} - 1041
- 1042
fn render_approval(approval: &ApprovalPayload, markup: Markup) -> String { - 1043
let mut text = format!("{}\n\n{}", approval.title, approval.detail); - 1044
if let Some(expires_at) = &approval.expires_at { - 1045
text.push_str(&format!("\n\nExpires: {expires_at}")); - 1046
} - 1047
if !approval.actions.is_empty() { - 1048
text.push_str("\n\nActions: "); - 1049
text.push_str( - 1050
&approval - 1051
.actions - 1052
.iter() - 1053
.map(|action| action.label.as_str()) - 1054
.collect::<Vec<_>>() - 1055
.join(" / "), - 1056
); - 1057
} - 1058
if matches!(markup, Markup::TelegramHtml) { - 1059
escape_html(&text) - 1060
} else { - 1061
text - 1062
} - 1063
} - 1064
- 1065
fn parse_blocks(source: &str) -> Vec<Block> { - 1066
let lines: Vec<&str> = source.lines().collect(); - 1067
let mut blocks = Vec::new(); - 1068
let mut index = 0; - 1069
let mut block_number = 0; - 1070
- 1071
while index < lines.len() { - 1072
if lines[index].trim().is_empty() { - 1073
index += 1; - 1074
continue; - 1075
} - 1076
let id = || format!("block-{block_number}"); - 1077
let line = lines[index]; - 1078
let trimmed = line.trim_start(); - 1079
- 1080
if let Some(fence) = trimmed.strip_prefix("```") { - 1081
let language = (!fence.trim().is_empty()).then(|| fence.trim().to_string()); - 1082
let start = index + 1; - 1083
index = start; - 1084
while index < lines.len() && !lines[index].trim_start().starts_with("```") { - 1085
index += 1; - 1086
} - 1087
blocks.push(Block::Code { - 1088
id: id(), - 1089
language, - 1090
content: lines[start..index].join("\n"), - 1091
}); - 1092
block_number += 1; - 1093
index += usize::from(index < lines.len()); - 1094
continue; - 1095
} - 1096
- 1097
if let Some((level, text)) = heading(line) { - 1098
blocks.push(Block::Heading { - 1099
id: id(), - 1100
level, - 1101
text: text.to_string(), - 1102
}); - 1103
block_number += 1; - 1104
index += 1; - 1105
continue; - 1106
} - 1107
- 1108
if let Some((ordered, item)) = list_item(line) { - 1109
let mut items = vec![item.to_string()]; - 1110
index += 1; - 1111
while index < lines.len() { - 1112
let Some((same_order, next)) = list_item(lines[index]) else { - 1113
break; - 1114
}; - 1115
if same_order != ordered { - 1116
break; - 1117
} - 1118
items.push(next.to_string()); - 1119
index += 1; - 1120
} - 1121
blocks.push(Block::List { - 1122
id: id(), - 1123
ordered, - 1124
items, - 1125
}); - 1126
block_number += 1; - 1127
continue; - 1128
} - 1129
- 1130
if let Some(text) = trimmed.strip_prefix("> ") { - 1131
blocks.push(Block::Quote { - 1132
id: id(), - 1133
text: text.to_string(), - 1134
}); - 1135
block_number += 1; - 1136
index += 1; - 1137
continue; - 1138
} - 1139
- 1140
if trimmed.starts_with('|') && trimmed.ends_with('|') { - 1141
let start = index; - 1142
index += 1; - 1143
while index < lines.len() - 1144
&& lines[index].trim_start().starts_with('|') - 1145
&& lines[index].trim_end().ends_with('|') - 1146
{ - 1147
index += 1; - 1148
} - 1149
blocks.push(Block::RawMarkdown { - 1150
id: id(), - 1151
markdown: lines[start..index].join("\n"), - 1152
reason: "table semantics require a channel capability".into(), - 1153
}); - 1154
block_number += 1; - 1155
continue; - 1156
} - 1157
- 1158
let start = index; - 1159
index += 1; - 1160
while index < lines.len() - 1161
&& !lines[index].trim().is_empty() - 1162
&& heading(lines[index]).is_none() - 1163
&& list_item(lines[index]).is_none() - 1164
&& !lines[index].trim_start().starts_with("> ") - 1165
&& !lines[index].trim_start().starts_with("```") - 1166
{ - 1167
index += 1; - 1168
} - 1169
blocks.push(Block::Paragraph { - 1170
id: id(), - 1171
text: lines[start..index].join("\n"), - 1172
}); - 1173
block_number += 1; - 1174
} - 1175
blocks - 1176
} - 1177
- 1178
fn heading(line: &str) -> Option<(u8, &str)> { - 1179
let trimmed = line.trim_start(); - 1180
let count = trimmed.chars().take_while(|c| *c == '#').count(); - 1181
if (1..=6).contains(&count) && trimmed.as_bytes().get(count) == Some(&b' ') { - 1182
Some((count as u8, trimmed[count + 1..].trim())) - 1183
} else { - 1184
None - 1185
} - 1186
} - 1187
- 1188
fn list_item(line: &str) -> Option<(bool, &str)> { - 1189
let trimmed = line.trim_start(); - 1190
if let Some(item) = trimmed - 1191
.strip_prefix("- ") - 1192
.or_else(|| trimmed.strip_prefix("* ")) - 1193
.or_else(|| trimmed.strip_prefix("+ ")) - 1194
{ - 1195
return Some((false, item)); - 1196
} - 1197
let digit_count = trimmed.chars().take_while(|c| c.is_ascii_digit()).count(); - 1198
if digit_count > 0 && trimmed.as_bytes().get(digit_count..digit_count + 2) == Some(b". ") { - 1199
return Some((true, &trimmed[digit_count + 2..])); - 1200
} - 1201
None - 1202
} - 1203
- 1204
fn render_plain(answer: &AnswerDraft, project_structured: bool, skills: &SkillRegistry) -> String { - 1205
answer - 1206
.blocks - 1207
.iter() - 1208
.map(|block| match block { - 1209
Block::Heading { text, .. } => text.clone(), - 1210
Block::Paragraph { text, .. } => text.clone(), - 1211
Block::List { ordered, items, .. } => items - 1212
.iter() - 1213
.enumerate() - 1214
.map(|(index, item)| { - 1215
if *ordered { - 1216
format!("{}. {item}", index + 1) - 1217
} else { - 1218
format!("- {item}") - 1219
} - 1220
}) - 1221
.collect::<Vec<_>>() - 1222
.join("\n"), - 1223
Block::Quote { text, .. } => format!("> {text}"), - 1224
Block::Code { - 1225
content, language, .. - 1226
} if project_structured && language.as_deref() == Some("vak") => { - 1227
parse_fragment_with(content, skills) - 1228
.map(|output| structured_markdown(&output)) - 1229
.unwrap_or_else(|_| content.clone()) - 1230
} - 1231
Block::Code { content, .. } - 1232
| Block::RawMarkdown { - 1233
markdown: content, .. - 1234
} => content.clone(), - 1235
}) - 1236
.collect::<Vec<_>>() - 1237
.join("\n\n") - 1238
} - 1239
- 1240
fn escape_html(value: &str) -> String { - 1241
value - 1242
.replace('&', "&") - 1243
.replace('<', "<") - 1244
.replace('>', ">") - 1245
} - 1246
- 1247
fn chunk_text(text: &str, max_chars: Option<usize>) -> Vec<String> { - 1248
let Some(max_chars) = max_chars else { - 1249
return vec![text.to_string()]; - 1250
}; - 1251
if text.is_empty() { - 1252
return vec![String::new()]; - 1253
} - 1254
let chars: Vec<char> = text.chars().collect(); - 1255
let mut chunks = Vec::new(); - 1256
let mut start = 0; - 1257
while start < chars.len() { - 1258
let hard_end = (start + max_chars).min(chars.len()); - 1259
let end = chars[start..hard_end] - 1260
.iter() - 1261
.rposition(|c| *c == '\n') - 1262
.map(|offset| start + offset) - 1263
.filter(|candidate| *candidate > start) - 1264
.unwrap_or(hard_end); - 1265
chunks.push(chars[start..end].iter().collect()); - 1266
start = end; - 1267
while start < chars.len() && chars[start] == '\n' { - 1268
start += 1; - 1269
} - 1270
} - 1271
chunks - 1272
} - 1273
- 1274
/// Chunk a Slack/Discord `mrkdwn`-ish message on line boundaries like - 1275
/// [`chunk_text`], but close and reopen a fenced code block (```` ``` ````) - 1276
/// at any boundary that falls inside one, so a split never leaves a - 1277
/// dangling fence that swallows the rest of the message as code. - 1278
fn chunk_markdown_preserving_fences(text: &str, max_chars: Option<usize>) -> Vec<String> { - 1279
let Some(max_chars) = max_chars else { - 1280
return vec![text.to_string()]; - 1281
}; - 1282
if text.chars().count() <= max_chars { - 1283
return vec![text.to_string()]; - 1284
} - 1285
- 1286
let mut chunks = Vec::new(); - 1287
let mut current = String::new(); - 1288
let mut in_fence = false; - 1289
let mut fence_header = String::new(); - 1290
- 1291
for line in text.split_inclusive('\n') { - 1292
let trimmed = line.trim_start().trim_end_matches('\n'); - 1293
let is_fence_marker = trimmed.starts_with("```"); - 1294
- 1295
let reserve = if in_fence { 4 } else { 0 }; // room for a closing "```\n" - 1296
if !current.is_empty() - 1297
&& current.chars().count() + line.chars().count() + reserve > max_chars - 1298
{ - 1299
if in_fence { - 1300
current.push_str("```\n"); - 1301
} - 1302
chunks.push(std::mem::take(&mut current)); - 1303
if in_fence { - 1304
current.push_str(&fence_header); - 1305
current.push('\n'); - 1306
} - 1307
} - 1308
current.push_str(line); - 1309
if is_fence_marker { - 1310
if !in_fence { - 1311
fence_header = trimmed.to_string(); - 1312
} - 1313
in_fence = !in_fence; - 1314
} - 1315
} - 1316
if !current.trim().is_empty() { - 1317
chunks.push(current); - 1318
} - 1319
chunks - 1320
} - 1321
- 1322
pub mod worker { - 1323
//! Line-oriented worker protocol used by the future server outbox. - 1324
- 1325
use super::{DeliveryJob, DeliveryPacket, render}; - 1326
use serde::{Deserialize, Serialize}; - 1327
use std::io::{self, BufRead, Write}; - 1328
- 1329
pub const WORKER_PROTOCOL_VERSION: u16 = 1; - 1330
pub const WORKER_SUBCOMMAND: &str = "__delivery_worker"; - 1331
- 1332
#[derive(Debug, Serialize, Deserialize)] - 1333
pub struct WorkerRequest { - 1334
pub protocol_version: u16, - 1335
pub job: DeliveryJob, - 1336
} - 1337
- 1338
#[derive(Debug, Serialize, Deserialize)] - 1339
pub struct WorkerResponse { - 1340
pub protocol_version: u16, - 1341
pub job_id: Option<String>, - 1342
pub packet: Option<DeliveryPacket>, - 1343
pub error: Option<String>, - 1344
} - 1345
- 1346
pub fn process_line(line: &str) -> String { - 1347
let parsed = serde_json::from_str::<WorkerRequest>(line); - 1348
let response = match parsed { - 1349
Ok(request) if request.protocol_version == WORKER_PROTOCOL_VERSION => { - 1350
let job_id = request.job.job_id.clone(); - 1351
match render(&request.job) { - 1352
Ok(packet) => WorkerResponse { - 1353
protocol_version: WORKER_PROTOCOL_VERSION, - 1354
job_id: Some(job_id), - 1355
packet: Some(packet), - 1356
error: None, - 1357
}, - 1358
Err(error) => WorkerResponse { - 1359
protocol_version: WORKER_PROTOCOL_VERSION, - 1360
job_id: Some(job_id), - 1361
packet: None, - 1362
error: Some(error.to_string()), - 1363
}, - 1364
} - 1365
} - 1366
Ok(request) => WorkerResponse { - 1367
protocol_version: WORKER_PROTOCOL_VERSION, - 1368
job_id: Some(request.job.job_id), - 1369
packet: None, - 1370
error: Some(format!( - 1371
"unsupported worker protocol version {}", - 1372
request.protocol_version - 1373
)), - 1374
}, - 1375
Err(error) => WorkerResponse { - 1376
protocol_version: WORKER_PROTOCOL_VERSION, - 1377
job_id: None, - 1378
packet: None, - 1379
error: Some(format!("invalid delivery worker request: {error}")), - 1380
}, - 1381
}; - 1382
serde_json::to_string(&response).unwrap_or_else(|error| { - 1383
format!( - 1384
"{{\"protocol_version\":{WORKER_PROTOCOL_VERSION},\"error\":\"response serialization failed: {error}\"}}" - 1385
) - 1386
}) - 1387
} - 1388
- 1389
pub fn run_stdio() -> i32 { - 1390
let stdin = io::stdin(); - 1391
let stdout = io::stdout(); - 1392
let mut output = stdout.lock(); - 1393
for line in stdin.lock().lines() { - 1394
match line { - 1395
Ok(line) => { - 1396
if writeln!(output, "{}", process_line(&line)).is_err() { - 1397
return 1; - 1398
} - 1399
} - 1400
Err(_) => return 1, - 1401
} - 1402
} - 1403
0 - 1404
} - 1405
} - 1406
- 1407
#[cfg(test)] - 1408
#[allow(clippy::expect_used, clippy::panic)] - 1409
mod tests { - 1410
use super::*; - 1411
- 1412
#[test] - 1413
fn card_note_requires_explicit_additional_text() { - 1414
assert_eq!(supplemental_card_note("The plan is shown above."), None); - 1415
assert_eq!( - 1416
supplemental_card_note("Note: Bring an umbrella."), - 1417
Some("Bring an umbrella.") - 1418
); - 1419
assert_eq!( - 1420
supplemental_card_note("Additional note: Allow extra travel time."), - 1421
Some("Allow extra travel time.") - 1422
); - 1423
assert_eq!(supplemental_card_note("Note: "), None); - 1424
} - 1425
- 1426
fn job(markup: Markup, max_chars: Option<usize>) -> DeliveryJob { - 1427
let source = - 1428
"# Result\n\nDone.\n\n```rust\nlet x = 1;\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"; - 1429
DeliveryJob { - 1430
job_id: "job-1".into(), - 1431
target: "test:one".into(), - 1432
kind: DeliveryKind::Assistant, - 1433
content: DeliveryContent::Answer(AnswerDraft::from_markdown(source)), - 1434
profile: DeliveryProfile { - 1435
surface: "test".into(), - 1436
markup, - 1437
max_chars, - 1438
supports_tables: false, - 1439
supports_code_blocks: true, - 1440
supports_links: true, - 1441
supports_actions: false, - 1442
template: None, - 1443
posture: DeliveryPosture::default(), - 1444
}, - 1445
skill_registry: None, - 1446
} - 1447
} - 1448
- 1449
#[test] - 1450
fn source_and_unknown_blocks_are_preserved() { - 1451
let DeliveryContent::Answer(answer) = &job(Markup::Plain, None).content else { - 1452
panic!("test job must contain an answer"); - 1453
}; - 1454
assert!(answer.source_markdown.contains("| a | b |")); - 1455
assert!( - 1456
answer - 1457
.blocks - 1458
.iter() - 1459
.any(|block| matches!(block, Block::RawMarkdown { .. })) - 1460
); - 1461
} - 1462
- 1463
#[test] - 1464
fn packet_keeps_exact_fallback_and_coverage() { - 1465
let input = job(Markup::TelegramHtml, Some(4096)); - 1466
let packet = render(&input).expect("valid delivery job"); - 1467
let DeliveryContent::Answer(answer) = &input.content else { - 1468
panic!("test job must contain an answer"); - 1469
}; - 1470
assert_eq!(packet.fallback_markdown, answer.source_markdown); - 1471
assert_eq!(packet.coverage.len(), answer.blocks.len()); - 1472
assert!( - 1473
packet - 1474
.chunks - 1475
.iter() - 1476
.all(|chunk| chunk.chars().count() <= 4096) - 1477
); - 1478
assert!(matches!(packet.payload, DeliveryPayload::Text(_))); - 1479
let presentation = packet - 1480
.presentation - 1481
.expect("schema v2 packet has a timeline"); - 1482
assert_eq!(presentation.items.len(), 1); - 1483
let OutputContent::Document { document } = &presentation.items[0].content else { - 1484
panic!("answer must project to a document"); - 1485
}; - 1486
assert_eq!(document.source_markdown, answer.source_markdown); - 1487
assert_eq!(document.coverage.len(), document.blocks.len()); - 1488
assert!( - 1489
document - 1490
.blocks - 1491
.iter() - 1492
.any(|block| matches!(block, DocumentBlock::Table { .. })) - 1493
); - 1494
} - 1495
- 1496
#[test] - 1497
fn specialized_structured_outputs_keep_one_lossless_fallback_across_surfaces() { - 1498
// These are deliberately generic semantic shapes: the delivery layer may - 1499
// lower them for a constrained channel, but it must never replace the - 1500
// source answer that replay/export/voice consumers depend on. - 1501
let source = concat!( - 1502
"# Weekly review\n\n", - 1503
"```vak\n{\"semantic_type\":\"metric\",\"payload\":{\"label\":\"items\",\"value\":12}}\n```\n\n", - 1504
"```vak\n{\"semantic_type\":\"collection\",\"payload\":{\"title\":\"Next\",\"items\":[{\"label\":\"One\"}]}}\n```\n\n", - 1505
"```vak\n{\"semantic_type\":\"steps\",\"payload\":{\"title\":\"Plan\",\"steps\":[{\"title\":\"Review\"}]}}\n```\n", - 1506
); - 1507
for (surface, markup) in [ - 1508
("telegram", Markup::TelegramHtml), - 1509
("slack", Markup::SlackMrkdwn), - 1510
("discord", Markup::DiscordMarkdown), - 1511
("voice", Markup::Plain), - 1512
("webhook", Markup::Json), - 1513
] { - 1514
let input = DeliveryJob { - 1515
job_id: format!("specialized-{surface}"), - 1516
target: format!("test:{surface}"), - 1517
kind: DeliveryKind::Assistant, - 1518
content: DeliveryContent::Answer(AnswerDraft::from_markdown(source)), - 1519
profile: DeliveryProfile { - 1520
surface: surface.into(), - 1521
markup, - 1522
max_chars: Some(4096), - 1523
supports_tables: false, - 1524
supports_code_blocks: true, - 1525
supports_links: true, - 1526
supports_actions: false, - 1527
template: None, - 1528
posture: DeliveryPosture::default(), - 1529
}, - 1530
skill_registry: None, - 1531
}; - 1532
let packet = render(&input).expect("specialized output should render"); - 1533
assert_eq!(packet.fallback_markdown, source, "surface={surface}"); - 1534
} - 1535
} - 1536
- 1537
#[test] - 1538
fn every_supported_chat_surface_keeps_readable_output_and_fallback() { - 1539
let source = - 1540
"# Plan\n\nA short answer with [a link](https://example.com).\n\n- One\n- Two\n"; - 1541
for (surface, markup) in [ - 1542
("telegram", Markup::TelegramHtml), - 1543
("slack", Markup::SlackMrkdwn), - 1544
("discord", Markup::DiscordMarkdown), - 1545
] { - 1546
let mut input = job(markup, Some(256)); - 1547
input.content = DeliveryContent::Answer(AnswerDraft::from_markdown(source)); - 1548
input.profile.surface = surface.into(); - 1549
let packet = render(&input).expect("supported chat surface renders"); - 1550
assert_eq!(packet.fallback_markdown, source); - 1551
assert!(!packet.chunks.is_empty(), "{surface} emitted no chunks"); - 1552
assert!( - 1553
packet.chunks.iter().all(|chunk| !chunk.trim().is_empty()), - 1554
"{surface} emitted an empty chunk" - 1555
); - 1556
assert!( - 1557
packet - 1558
.chunks - 1559
.iter() - 1560
.all(|chunk| chunk.chars().count() <= 256) - 1561
); - 1562
} - 1563
} - 1564
- 1565
#[test] - 1566
fn packet_preserves_outcome_metadata_for_channel_consumers() { - 1567
let mut input = job(Markup::Markdown, None); - 1568
let DeliveryContent::Answer(answer) = &mut input.content else { - 1569
panic!("test job must contain an answer"); - 1570
}; - 1571
answer - 1572
.metadata - 1573
.insert("outcome_completion".into(), "partial".into()); - 1574
answer - 1575
.document - 1576
.metadata - 1577
.insert("outcome_evidence_state".into(), "stale".into()); - 1578
let packet = render(&input).expect("valid delivery job"); - 1579
let DeliveryContent::Answer(answer) = &input.content else { - 1580
panic!("test job must contain an answer"); - 1581
}; - 1582
assert_eq!( - 1583
answer - 1584
.metadata - 1585
.get("outcome_completion") - 1586
.map(String::as_str), - 1587
Some("partial") - 1588
); - 1589
assert_eq!( - 1590
packet - 1591
.presentation - 1592
.as_ref() - 1593
.and_then(|timeline| timeline.items.first()) - 1594
.and_then(|item| match &item.content { - 1595
OutputContent::Document { document } => document - 1596
.metadata - 1597
.get("outcome_evidence_state") - 1598
.map(String::as_str), - 1599
_ => None, - 1600
}), - 1601
Some("stale") - 1602
); - 1603
let presentation = packet.presentation.as_ref().expect("presentation"); - 1604
assert_eq!(presentation.items[0].status, OutputStatus::Partial); - 1605
assert!( - 1606
presentation - 1607
.diagnostics - 1608
.iter() - 1609
.any(|diagnostic| diagnostic.contains("incomplete or unverified")) - 1610
); - 1611
} - 1612
- 1613
#[test] - 1614
fn packet_export_round_trip_preserves_ordered_chunks_and_exact_fallback() { - 1615
// This transport-neutral fixture is the contract consumed by webhook, - 1616
// share/export, and chat adapters: presentation is optional, while - 1617
// chunks and the source fallback remain lossless and ordered. - 1618
let input = job(Markup::Markdown, Some(18)); - 1619
let packet = render(&input).expect("valid delivery job"); - 1620
let encoded = serde_json::to_vec(&packet).expect("packet is exportable"); - 1621
let decoded: DeliveryPacket = - 1622
serde_json::from_slice(&encoded).expect("export must be importable"); - 1623
assert_eq!(decoded.fallback_markdown, packet.fallback_markdown); - 1624
assert_eq!(decoded.chunks, packet.chunks); - 1625
assert_eq!( - 1626
decoded.chunks.concat(), - 1627
packet.chunks.concat(), - 1628
"chunk order must survive sharing/export" - 1629
); - 1630
assert_eq!(decoded.presentation, packet.presentation); - 1631
} - 1632
- 1633
#[test] - 1634
fn typed_results_keep_outcomes_independent() { - 1635
let mut input = job(Markup::Markdown, None); - 1636
let DeliveryContent::Answer(answer) = &mut input.content else { - 1637
panic!("test job must contain an answer"); - 1638
}; - 1639
answer.results = vec![ - 1640
AnswerResult { - 1641
id: "answer".into(), - 1642
source_markdown: "The answer is verified.".into(), - 1643
outcome: Some(ResultOutcome { - 1644
result_id: "answer".into(), - 1645
status: OutputStatus::Succeeded, - 1646
completion: Some("complete".into()), - 1647
evidence_state: Some("fresh".into()), - 1648
requirement_ids: vec!["r1".into()], - 1649
evidence_receipt_ids: vec!["receipt-1".into()], - 1650
evidence: Vec::new(), - 1651
human_review: None, - 1652
}), - 1653
}, - 1654
AnswerResult { - 1655
id: "artifact".into(), - 1656
source_markdown: "The artifact still needs review.".into(), - 1657
outcome: Some(ResultOutcome { - 1658
result_id: "artifact".into(), - 1659
status: OutputStatus::Partial, - 1660
completion: Some("partial".into()), - 1661
evidence_state: Some("stale".into()), - 1662
requirement_ids: vec!["r2".into()], - 1663
evidence_receipt_ids: Vec::new(), - 1664
evidence: Vec::new(), - 1665
human_review: Some("required".into()), - 1666
}), - 1667
}, - 1668
]; - 1669
let packet = render(&input).expect("valid delivery job"); - 1670
assert!(packet.fallback_markdown.contains("The answer is verified.")); - 1671
assert!( - 1672
packet - 1673
.fallback_markdown - 1674
.contains("artifact still needs review") - 1675
); - 1676
assert!(matches!( - 1677
&packet.payload, - 1678
DeliveryPayload::Text(text) if text.contains("Result 'artifact'") - 1679
)); - 1680
let timeline = packet.presentation.expect("presentation"); - 1681
assert_eq!(timeline.items.len(), 2); - 1682
assert_eq!(timeline.items[0].status, OutputStatus::Succeeded); - 1683
assert_eq!(timeline.items[1].status, OutputStatus::Partial); - 1684
assert_eq!( - 1685
timeline.items[1] - 1686
.outcome - 1687
.as_ref() - 1688
.and_then(|o| o.human_review.as_deref()), - 1689
Some("required") - 1690
); - 1691
assert_eq!(timeline.diagnostics.len(), 1); - 1692
} - 1693
- 1694
#[test] - 1695
fn stale_evidence_downgrades_even_a_complete_claim() { - 1696
let mut input = job(Markup::Markdown, None); - 1697
let DeliveryContent::Answer(answer) = &mut input.content else { - 1698
panic!("test job must contain an answer"); - 1699
}; - 1700
answer - 1701
.metadata - 1702
.insert("outcome_completion".into(), "complete".into()); - 1703
answer - 1704
.metadata - 1705
.insert("outcome_evidence_state".into(), "stale".into()); - 1706
let packet = render(&input).expect("valid delivery job"); - 1707
let presentation = packet.presentation.as_ref().expect("presentation"); - 1708
assert_eq!(presentation.items[0].status, OutputStatus::Partial); - 1709
assert!( - 1710
matches!(&packet.payload, DeliveryPayload::Text(text) if text.contains("Outcome incomplete or unverified")) - 1711
); - 1712
} - 1713
- 1714
#[test] - 1715
fn stale_evidence_warning_survives_messaging_renderers() { - 1716
for markup in [ - 1717
Markup::TelegramHtml, - 1718
Markup::SlackMrkdwn, - 1719
Markup::DiscordMarkdown, - 1720
] { - 1721
let mut input = job(markup, None); - 1722
let DeliveryContent::Answer(answer) = &mut input.content else { - 1723
panic!("test job must contain an answer"); - 1724
}; - 1725
answer - 1726
.metadata - 1727
.insert("outcome_completion".into(), "complete".into()); - 1728
answer - 1729
.metadata - 1730
.insert("outcome_evidence_state".into(), "stale".into()); - 1731
let packet = render(&input).expect("valid delivery job"); - 1732
assert!( - 1733
matches!(&packet.payload, DeliveryPayload::Text(text) if text.contains("Outcome incomplete or unverified")), - 1734
"markup {markup:?}: {:?}", - 1735
packet.payload - 1736
); - 1737
} - 1738
} - 1739
- 1740
#[test] - 1741
fn typed_result_warning_survives_all_text_channels() { - 1742
for markup in [ - 1743
Markup::Plain, - 1744
Markup::Markdown, - 1745
Markup::TelegramHtml, - 1746
Markup::SlackMrkdwn, - 1747
Markup::DiscordMarkdown, - 1748
] { - 1749
let mut input = job(markup, None); - 1750
let DeliveryContent::Answer(answer) = &mut input.content else { - 1751
panic!("test job must contain an answer"); - 1752
}; - 1753
answer.results.push(AnswerResult { - 1754
id: "needs-review".into(), - 1755
source_markdown: "A result with an evidence gap.".into(), - 1756
outcome: Some(ResultOutcome { - 1757
result_id: "needs-review".into(), - 1758
status: OutputStatus::Partial, - 1759
completion: Some("partial".into()), - 1760
evidence_state: Some("unknown".into()), - 1761
requirement_ids: vec!["evidence".into()], - 1762
evidence_receipt_ids: Vec::new(), - 1763
evidence: Vec::new(), - 1764
human_review: Some("required".into()), - 1765
}), - 1766
}); - 1767
let packet = render(&input).expect("valid delivery job"); - 1768
assert!(matches!( - 1769
&packet.payload, - 1770
DeliveryPayload::Text(text) if text.contains("Result 'needs-review'") - 1771
)); - 1772
} - 1773
} - 1774
- 1775
#[test] - 1776
fn obsolete_answer_schema_is_refused_without_migration() { - 1777
let mut input = job(Markup::Plain, None); - 1778
let DeliveryContent::Answer(answer) = &mut input.content else { - 1779
panic!("test job must contain an answer"); - 1780
}; - 1781
answer.schema_version = 1; - 1782
answer.document = PresentationDocument::default(); - 1783
assert!(matches!( - 1784
render(&input), - 1785
Err(DeliveryError::UnsupportedSchema(1)) - 1786
)); - 1787
} - 1788
- 1789
#[test] - 1790
fn chunking_is_unicode_safe_and_prefers_line_boundaries() { - 1791
let input = DeliveryJob { - 1792
job_id: "job-2".into(), - 1793
target: "test:two".into(), - 1794
kind: DeliveryKind::Assistant, - 1795
content: DeliveryContent::Answer(AnswerDraft::from_markdown("éééé\nzzzz")), - 1796
profile: DeliveryProfile { - 1797
max_chars: Some(4), - 1798
..DeliveryProfile::plain("test") - 1799
}, - 1800
skill_registry: None, - 1801
}; - 1802
let packet = render(&input).expect("valid delivery job"); - 1803
assert!(packet.chunks.iter().all(|chunk| chunk.chars().count() <= 4)); - 1804
assert_eq!(packet.chunks, vec!["éééé", "zzzz"]); - 1805
} - 1806
- 1807
#[test] - 1808
fn user_template_can_replace_layout_without_touching_source() { - 1809
let mut input = job(Markup::Plain, None); - 1810
input.profile.template = Some(TemplateSpec { - 1811
id: "compact-result".into(), - 1812
revision: 1, - 1813
origin: TemplateOrigin::User, - 1814
activation: TemplateActivation::Active, - 1815
nodes: vec![ - 1816
TemplateNode::Literal { text: "[".into() }, - 1817
TemplateNode::Slot { - 1818
slot: TemplateSlot::Title, - 1819
}, - 1820
TemplateNode::Literal { text: "]\n".into() }, - 1821
TemplateNode::Slot { - 1822
slot: TemplateSlot::Body, - 1823
}, - 1824
], - 1825
}); - 1826
let packet = render(&input).expect("valid template"); - 1827
let DeliveryPayload::Text(text) = packet.payload else { - 1828
panic!("plain profile must produce text"); - 1829
}; - 1830
assert!(text.starts_with("[Result]\n")); - 1831
assert_eq!( - 1832
packet.fallback_markdown, - 1833
"# Result\n\nDone.\n\n```rust\nlet x = 1;\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n" - 1834
); - 1835
} - 1836
- 1837
#[test] - 1838
fn agent_template_requires_activation_and_user_templates_win() { - 1839
let mut registry = TemplateRegistry::default(); - 1840
registry - 1841
.upsert(TemplateSpec { - 1842
id: "result".into(), - 1843
revision: 2, - 1844
origin: TemplateOrigin::AgentProposal, - 1845
activation: TemplateActivation::Proposed, - 1846
nodes: Vec::new(), - 1847
}) - 1848
.expect("valid proposal"); - 1849
assert!(registry.resolve("result").is_none()); - 1850
assert!(registry.activate_proposal("result", 2)); - 1851
assert_eq!(registry.resolve("result").map(|t| t.revision), Some(2)); - 1852
- 1853
registry - 1854
.upsert(TemplateSpec { - 1855
id: "result".into(), - 1856
revision: 1, - 1857
origin: TemplateOrigin::User, - 1858
activation: TemplateActivation::Active, - 1859
nodes: Vec::new(), - 1860
}) - 1861
.expect("valid user template"); - 1862
assert_eq!( - 1863
registry.resolve("result").map(|t| t.origin), - 1864
Some(TemplateOrigin::User) - 1865
); - 1866
} - 1867
- 1868
#[test] - 1869
fn control_plane_messages_cannot_be_templated_or_flattened() { - 1870
let input = DeliveryJob { - 1871
job_id: "approval-1".into(), - 1872
target: "test:approval".into(), - 1873
kind: DeliveryKind::System, - 1874
content: DeliveryContent::Text { - 1875
markdown: "system instruction".into(), - 1876
}, - 1877
profile: DeliveryProfile::plain("test"), - 1878
skill_registry: None, - 1879
}; - 1880
let error = render(&input).expect_err("system content must stay on event stream"); - 1881
assert!(error.to_string().contains("control-plane")); - 1882
} - 1883
- 1884
#[test] - 1885
fn approval_actions_survive_text_rendering() { - 1886
let action = DeliveryAction { - 1887
id: "approve".into(), - 1888
label: "Approve".into(), - 1889
verb: "approve".into(), - 1890
data: BTreeMap::new(), - 1891
}; - 1892
let input = DeliveryJob { - 1893
job_id: "approval-2".into(), - 1894
target: "test:approval".into(), - 1895
kind: DeliveryKind::Approval, - 1896
content: DeliveryContent::Approval(ApprovalPayload { - 1897
request_id: "request-1".into(), - 1898
title: "Run command?".into(), - 1899
detail: "echo hello".into(), - 1900
expires_at: None, - 1901
actions: vec![action], - 1902
}), - 1903
profile: DeliveryProfile::plain("test"), - 1904
skill_registry: None, - 1905
}; - 1906
let packet = render(&input).expect("approval should render"); - 1907
assert_eq!(packet.actions.len(), 1); - 1908
assert!( - 1909
packet - 1910
.diagnostics - 1911
.iter() - 1912
.any(|diagnostic| diagnostic.contains("actions preserved")) - 1913
); - 1914
} - 1915
- 1916
#[test] - 1917
fn worker_returns_values_for_bad_input() { - 1918
let response = worker::process_line("not-json"); - 1919
assert!(response.contains("invalid delivery worker request")); - 1920
} - 1921
- 1922
fn fallback_text(packet: &DeliveryPacket) -> String { - 1923
match &packet.payload { - 1924
DeliveryPayload::Text(text) => text.clone(), - 1925
DeliveryPayload::Structured(content) => match content { - 1926
DeliveryContent::Answer(answer) => answer.source_markdown.clone(), - 1927
DeliveryContent::Text { markdown } => markdown.clone(), - 1928
DeliveryContent::Progress(progress) => { - 1929
format!("{}: {}", progress.label, progress.state) - 1930
} - 1931
DeliveryContent::ToolResult(payload) => payload.output.clone(), - 1932
_ => packet.fallback_markdown.clone(), - 1933
}, - 1934
} - 1935
} - 1936
- 1937
#[test] - 1938
fn structured_fence_projected_on_telegram_html() { - 1939
let source = "# Result\n\n```vak\n{\"semantic_type\":\"metric\",\"payload\":{\"label\":\"tokens\",\"value\":128}}\n```\n\nSummary text here.\n"; - 1940
let input = DeliveryJob { - 1941
job_id: "struct-1".into(), - 1942
target: "test:one".into(), - 1943
kind: DeliveryKind::Assistant, - 1944
content: DeliveryContent::Answer(AnswerDraft::from_markdown(source)), - 1945
profile: DeliveryProfile { - 1946
surface: "telegram".into(), - 1947
markup: Markup::TelegramHtml, - 1948
max_chars: Some(4000), - 1949
supports_tables: false, - 1950
supports_code_blocks: true, - 1951
supports_links: true, - 1952
supports_actions: false, - 1953
template: None, - 1954
posture: DeliveryPosture::default(), - 1955
}, - 1956
skill_registry: None, - 1957
}; - 1958
let packet = render(&input).expect("valid delivery job"); - 1959
let text = fallback_text(&packet); - 1960
assert!( - 1961
text.contains("tokens"), - 1962
"projected text should contain the metric label" - 1963
); - 1964
assert!( - 1965
text.contains("128"), - 1966
"projected text should contain the metric value" - 1967
); - 1968
assert!( - 1969
text.contains("Summary text here"), - 1970
"non-fence content preserved" - 1971
); - 1972
assert!( - 1973
!text.contains("```vak"), - 1974
"raw vak fence must be projected, not shown verbatim" - 1975
); - 1976
} - 1977
- 1978
#[test] - 1979
fn structured_fence_projected_on_slack_mrkdwn() { - 1980
let source = "Done.\n\n```vak\n{\"semantic_type\":\"link.preview\",\"payload\":{\"url\":\"https://example.com\",\"title\":\"Example\"}}\n```\n"; - 1981
let input = DeliveryJob { - 1982
job_id: "struct-2".into(), - 1983
target: "test:one".into(), - 1984
kind: DeliveryKind::Assistant, - 1985
content: DeliveryContent::Answer(AnswerDraft::from_markdown(source)), - 1986
profile: DeliveryProfile { - 1987
surface: "slack".into(), - 1988
markup: Markup::SlackMrkdwn, - 1989
max_chars: Some(3900), - 1990
supports_tables: false, - 1991
supports_code_blocks: true, - 1992
supports_links: true, - 1993
supports_actions: false, - 1994
template: None, - 1995
posture: DeliveryPosture::default(), - 1996
}, - 1997
skill_registry: None, - 1998
}; - 1999
let packet = render(&input).expect("valid delivery job"); - 2000
let text = fallback_text(&packet);
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.