- 1464
facts - 1465
.iter() - 1466
.filter_map(Value::as_str) - 1467
.collect::<Vec<_>>() - 1468
.join(", ") - 1469
}) - 1470
.unwrap_or_default(); - 1471
let text = [ - 1472
option["detail"].as_str().unwrap_or_default(), - 1473
facts.as_str(), - 1474
] - 1475
.into_iter() - 1476
.filter(|part| !part.is_empty()) - 1477
.collect::<Vec<_>>() - 1478
.join(" · "); - 1479
if text.is_empty() { - 1480
lines.push(format!(" - Option: {name}")); - 1481
} else { - 1482
lines.push(format!(" - Option: {name}: {text}")); - 1483
} - 1484
} - 1485
} - 1486
} - 1487
} - 1488
"ui.preview" => lines.push(format!( - 1489
"Preview: {}\nFile: {}", - 1490
title, - 1491
p["artifact_path"].as_str().unwrap_or_default() - 1492
)), - 1493
_ => {} - 1494
} - 1495
let json = serde_json::to_string_pretty(p).unwrap_or_else(|_| p.to_string()); - 1496
let fence = "`".repeat( - 1497
json.split(|c| c != '`') - 1498
.map(str::len) - 1499
.max() - 1500
.unwrap_or(0) - 1501
.max(2) - 1502
+ 1, - 1503
); - 1504
lines.push(format!("{fence}json\n{json}\n{fence}")); - 1505
lines.join("\n\n") - 1506
} - 1507
- 1508
/// Replace validated rich fences in-place with their deterministic text - 1509
/// projection. Ordinary Markdown and invalid/incomplete fences stay exact. - 1510
/// - 1511
/// Uses the built-in skill registry. For plugin-contributed semantic types, - 1512
/// use [`project_structured_fences_with`]. - 1513
pub fn project_structured_fences(source: &str) -> String { - 1514
project_structured_fences_with(source, &built_in_skill_registry()) - 1515
} - 1516
- 1517
/// Like [`project_structured_fences`] but validates against an arbitrary - 1518
/// (possibly plugin-extended) skill registry. Called by `render_content` - 1519
/// with the `DeliveryJob.skill_registry` field (the Core's merged registry) - 1520
/// when available, falling back to builtins in the worker. - 1521
pub fn project_structured_fences_with(source: &str, skills: &SkillRegistry) -> String { - 1522
use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd}; - 1523
let mut replacements = Vec::new(); - 1524
let mut pending: Option<(usize, String)> = None; - 1525
for (event, range) in Parser::new(source).into_offset_iter() { - 1526
match event { - 1527
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(language))) - 1528
if language.as_ref() == "vak" => - 1529
{ - 1530
pending = Some((range.start, String::new())); - 1531
} - 1532
Event::Text(text) => { - 1533
if let Some((_, content)) = &mut pending { - 1534
content.push_str(&text); - 1535
} - 1536
} - 1537
Event::End(TagEnd::CodeBlock) => { - 1538
if let Some((start, content)) = pending.take() - 1539
&& let Ok(output) = parse_fragment_with(&content, skills) - 1540
{ - 1541
replacements.push((start..range.end, structured_markdown(&output))); - 1542
} - 1543
} - 1544
_ => {} - 1545
} - 1546
} - 1547
let mut result = source.to_string(); - 1548
for (range, replacement) in replacements.into_iter().rev() { - 1549
result.replace_range(range, &replacement); - 1550
} - 1551
result - 1552
} - 1553
- 1554
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1555
pub struct LinkPreview { - 1556
pub url: String, - 1557
pub canonical_url: Option<String>, - 1558
pub title: String, - 1559
pub description: Option<String>, - 1560
pub site_name: Option<String>, - 1561
pub image_url: Option<String>, - 1562
pub icon_url: Option<String>, - 1563
pub media_type: Option<String>, - 1564
pub published_at: Option<String>, - 1565
} - 1566
- 1567
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 1568
pub struct Metric { - 1569
pub label: String, - 1570
pub value: Value, - 1571
pub unit: Option<String>, - 1572
pub change: Option<f64>, - 1573
pub trend: Option<Vec<f64>>, - 1574
} - 1575
- 1576
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1577
pub struct MediaOutput { - 1578
pub source: String, - 1579
pub media_type: String, - 1580
pub alt: String, - 1581
pub thumbnail: Option<String>, - 1582
pub width: Option<u32>, - 1583
pub height: Option<u32>, - 1584
pub duration_ms: Option<u64>, - 1585
} - 1586
- 1587
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 1588
pub struct ChartOutput { - 1589
pub chart_type: String, - 1590
pub title: Option<String>, - 1591
pub x_label: Option<String>, - 1592
pub y_label: Option<String>, - 1593
pub series: Vec<ChartSeries>, - 1594
pub accessible_summary: String, - 1595
} - 1596
- 1597
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 1598
pub struct ChartSeries { - 1599
pub name: String, - 1600
pub points: Vec<ChartPoint>, - 1601
} - 1602
- 1603
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 1604
pub struct ChartPoint { - 1605
pub x: Value, - 1606
pub y: f64, - 1607
} - 1608
- 1609
#[derive(Debug, Clone, PartialEq, Eq)] - 1610
pub enum SkillError { - 1611
InvalidManifest(String), - 1612
UnknownType(String), - 1613
InvalidPayload(String), - 1614
MissingCapability(String), - 1615
} - 1616
- 1617
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] - 1618
pub struct SkillRegistry { - 1619
pub skills: BTreeMap<String, PresentationSkillManifest>, - 1620
} - 1621
- 1622
impl SkillRegistry { - 1623
pub fn register(&mut self, manifest: PresentationSkillManifest) -> Result<(), SkillError> { - 1624
if manifest.id.trim().is_empty() || manifest.version.trim().is_empty() { - 1625
return Err(SkillError::InvalidManifest( - 1626
"id and version are required".into(), - 1627
)); - 1628
} - 1629
if manifest.api != PRESENTATION_SKILL_API { - 1630
return Err(SkillError::InvalidManifest(format!( - 1631
"unsupported API {}", - 1632
manifest.api - 1633
))); - 1634
} - 1635
if manifest.provides.iter().any(|kind| kind.trim().is_empty()) { - 1636
return Err(SkillError::InvalidManifest("empty semantic type".into())); - 1637
} - 1638
if manifest.outcome_requirements.iter().any(|requirement| { - 1639
requirement.id.trim().is_empty() - 1640
|| requirement.kind.trim().is_empty() - 1641
|| requirement.description.trim().is_empty() - 1642
}) { - 1643
return Err(SkillError::InvalidManifest( - 1644
"outcome requirements need id, kind, and description".into(), - 1645
)); - 1646
} - 1647
if self.skills.iter().any(|(id, existing)| { - 1648
id != &manifest.id - 1649
&& manifest - 1650
.provides - 1651
.iter() - 1652
.any(|kind| existing.provides.iter().any(|owned| owned == kind)) - 1653
}) { - 1654
return Err(SkillError::InvalidManifest( - 1655
"semantic type is already owned by another skill".into(), - 1656
)); - 1657
} - 1658
self.skills.insert(manifest.id.clone(), manifest); - 1659
Ok(()) - 1660
} - 1661
- 1662
/// Return plugin-declared requirements for a validated semantic type. - 1663
/// Declarations are data only; the runtime still evaluates them and - 1664
/// applies its own permission and evidence rules. - 1665
pub fn outcome_requirements_for_type( - 1666
&self, - 1667
semantic_type: &str, - 1668
) -> Vec<&OutcomeRequirementDeclaration> { - 1669
self.skills - 1670
.values() - 1671
.filter(|skill| skill.provides.iter().any(|kind| kind == semantic_type)) - 1672
.flat_map(|skill| skill.outcome_requirements.iter()) - 1673
.collect() - 1674
} - 1675
- 1676
/// Merge plugin-contributed skill manifests into this registry. - 1677
/// Plugins declare presentation files in their manifest `components.presentation` - 1678
/// list. Each file is a JSON-encoded `PresentationSkillManifest` or - 1679
/// `PresentationRecipe`. Unknown files are silently skipped (a plugin - 1680
/// manifest can declare files for other systems). - 1681
pub fn merge_plugin_files(&mut self, files: &[(String, Vec<u8>)]) { - 1682
for (_path, bytes) in files { - 1683
if let Ok(json) = std::str::from_utf8(bytes) - 1684
&& let Ok(manifest) = serde_json::from_str::<PresentationSkillManifest>(json) - 1685
{ - 1686
let _ = self.register(manifest); - 1687
} - 1688
} - 1689
} - 1690
- 1691
pub fn get(&self, id: &str) -> Option<&PresentationSkillManifest> { - 1692
self.skills.get(id) - 1693
} - 1694
- 1695
/// Find the skill that declares a given semantic type. Returns the skill's - 1696
/// `(id, version)` so `parse_fragment_with` can attribute an output to - 1697
/// its real owner rather than the hardcoded `"core"` fallback. - 1698
pub fn find_by_type(&self, semantic_type: &str) -> Option<(&String, &String)> { - 1699
self.skills - 1700
.values() - 1701
.find(|skill| skill.provides.iter().any(|kind| kind == semantic_type)) - 1702
.map(|skill| (&skill.id, &skill.version)) - 1703
} - 1704
- 1705
pub fn validate( - 1706
&self, - 1707
output: &StructuredOutput, - 1708
surface: &str, - 1709
capabilities: &[String], - 1710
) -> Result<&RendererBinding, SkillError> { - 1711
let skill = self - 1712
.get(&output.skill_id) - 1713
.ok_or_else(|| SkillError::UnknownType(output.skill_id.clone()))?; - 1714
if output.schema_version != crate::PRESENTATION_SCHEMA_VERSION - 1715
|| output.skill_version != skill.version - 1716
{ - 1717
return Err(SkillError::InvalidPayload( - 1718
"unsupported structured output version".into(), - 1719
)); - 1720
} - 1721
if !skill - 1722
.provides - 1723
.iter() - 1724
.any(|kind| kind == &output.semantic_type) - 1725
{ - 1726
return Err(SkillError::UnknownType(output.semantic_type.clone())); - 1727
} - 1728
let renderer = skill - 1729
.renderers - 1730
.get(surface) - 1731
.ok_or_else(|| SkillError::MissingCapability(surface.into()))?; - 1732
for required in &renderer.requires { - 1733
if !capabilities.iter().any(|capability| capability == required) { - 1734
return Err(SkillError::MissingCapability(required.clone())); - 1735
} - 1736
} - 1737
validate_payload( - 1738
&output.semantic_type, - 1739
&output.payload, - 1740
skill.schema.as_ref(), - 1741
)?; - 1742
Ok(renderer) - 1743
} - 1744
} - 1745
- 1746
/// Validate a payload value against a minimal JSON Schema subset - 1747
/// (`type`, `required`). This avoids pulling in a schema crate; plugin - 1748
/// manifests that need richer validation declare their own renderer. - 1749
fn validate_against_schema(payload: &Value, schema: &Value) -> Result<(), SkillError> { - 1750
let Some(schema_obj) = schema.as_object() else { - 1751
return Err(SkillError::InvalidPayload( - 1752
"plugin schema must be a JSON object".into(), - 1753
)); - 1754
}; - 1755
if let Some(expected) = schema_obj.get("type").and_then(|t| t.as_str()) { - 1756
let matches = match expected { - 1757
"object" => payload.is_object(), - 1758
"array" => payload.is_array(), - 1759
"string" => payload.is_string(), - 1760
"number" => payload.is_number(), - 1761
"boolean" => payload.is_boolean(), - 1762
"null" => payload.is_null(), - 1763
_ => true, - 1764
}; - 1765
if !matches { - 1766
return Err(SkillError::InvalidPayload(format!( - 1767
"payload is not a {expected}" - 1768
))); - 1769
} - 1770
} - 1771
if let Some(required) = schema_obj.get("required").and_then(|r| r.as_array()) { - 1772
let Some(obj) = payload.as_object() else { - 1773
return Err(SkillError::InvalidPayload( - 1774
"payload must be an object for required check".into(), - 1775
)); - 1776
}; - 1777
for key in required.iter().flat_map(|k| k.as_str()) { - 1778
if !obj.contains_key(key) { - 1779
return Err(SkillError::InvalidPayload(format!( - 1780
"missing required field: {key}" - 1781
))); - 1782
} - 1783
} - 1784
} - 1785
Ok(()) - 1786
} - 1787
- 1788
fn validate_payload( - 1789
semantic_type: &str, - 1790
payload: &Value, - 1791
schema: Option<&Value>, - 1792
) -> Result<(), SkillError> { - 1793
if payload.to_string().len() > 1_000_000 { - 1794
return Err(SkillError::InvalidPayload("payload exceeds 1 MB".into())); - 1795
} - 1796
let object = payload.as_object(); - 1797
if let Some(object) = object { - 1798
validate_context(object)?; - 1799
} - 1800
// Plugin-declared schema takes precedence: if the skill ships a JSON - 1801
// Schema, validate against it. If not, fall through to the built-in - 1802
// type-by-type validators for known core types. - 1803
if let Some(schema) = schema { - 1804
return validate_against_schema(payload, schema); - 1805
} - 1806
let object = - 1807
object.ok_or_else(|| SkillError::InvalidPayload("payload must be an object".into()))?; - 1808
let strings = |value: &Value, keys: &[&str]| keys.iter().all(|key| value[*key].is_string()); - 1809
fn array<'a>(value: &'a Value, key: &str) -> Option<&'a Vec<Value>> { - 1810
value[key].as_array().filter(|items| items.len() <= 10_000) - 1811
} - 1812
let scalar = |value: &Value| { - 1813
value.is_null() || value.is_string() || value.is_number() || value.is_boolean() - 1814
}; - 1815
let nonnegative = |value: &Value| value.as_f64().is_some_and(|n| n.is_finite() && n >= 0.0); - 1816
let valid = match semantic_type { - 1817
"link.preview" => strings(payload, &["url", "title"]), - 1818
"metric" => { - 1819
(strings(payload, &["label"]) - 1820
&& object.contains_key("value") - 1821
&& scalar(&payload["value"])) - 1822
|| (object.len() >= 2 && object.values().any(scalar)) - 1823
} - 1824
"media.image" | "media.video" | "media.audio" => { - 1825
strings(payload, &["source", "media_type", "alt"]) - 1826
} - 1827
"chart" => { - 1828
strings(payload, &["accessible_summary"]) - 1829
&& matches!( - 1830
payload["chart_type"].as_str(), - 1831
Some("line" | "bar" | "area") - 1832
) - 1833
&& array(payload, "series").is_some_and(|series| { - 1834
series.iter().all(|s| { - 1835
strings(s, &["name"]) - 1836
&& array(s, "points").is_some_and(|points| { - 1837
points.iter().all(|p| { - 1838
(p["x"].is_number() || p["x"].is_string()) - 1839
&& p.get("y").is_some_and(|y| { - 1840
y.is_null() || y.as_f64().is_some_and(f64::is_finite) - 1841
}) - 1842
}) - 1843
}) - 1844
}) - 1845
}) - 1846
} - 1847
"research.synthesis" => array(payload, "sources").is_some_and(|sources| { - 1848
sources.iter().all(|s| strings(s, &["title", "url"])) - 1849
&& array(payload, "takeaways").is_some_and(|items| { - 1850
items.iter().all(|item| { - 1851
strings(item, &["text"]) - 1852
&& item.get("citation_indices").is_some_and(|indices| { - 1853
indices.as_array().is_some_and(|indices| { - 1854
!indices.is_empty() - 1855
&& indices.iter().all(|i| { - 1856
i.as_u64() - 1857
.is_some_and(|i| i > 0 && i <= sources.len() as u64) - 1858
}) - 1859
}) - 1860
}) - 1861
}) - 1862
}) - 1863
}), - 1864
"itinerary" => array(payload, "items").is_some_and(|items| { - 1865
items.iter().all(|item| { - 1866
strings(item, &["title"]) && item.get("detail").is_none_or(Value::is_string) - 1867
}) - 1868
}), - 1869
"coding.diff" => array(payload, "files").is_some_and(|files| { - 1870
files.iter().all(|file| { - 1871
strings(file, &["filename", "hunks"]) - 1872
&& file["additions"].is_u64() - 1873
&& file["deletions"].is_u64() - 1874
}) - 1875
}), - 1876
"test.report" => array(payload, "tests").is_some_and(|tests| { - 1877
tests.iter().all(|test| { - 1878
strings(test, &["name"]) - 1879
&& matches!( - 1880
test["status"].as_str(), - 1881
Some("passed" | "failed" | "skipped") - 1882
) - 1883
&& test.get("duration_ms").is_none_or(&nonnegative) - 1884
}) && ["total", "passed", "failed", "skipped"].iter().all(|key| { - 1885
payload.get(*key).is_none_or(|count| { - 1886
count.as_u64().is_some_and(|count| { - 1887
count - 1888
== if *key == "total" { - 1889
tests.len() - 1890
} else { - 1891
tests - 1892
.iter() - 1893
.filter(|test| test["status"].as_str() == Some(*key)) - 1894
.count() - 1895
} as u64 - 1896
}) - 1897
}) - 1898
}) - 1899
}), - 1900
"terminal.view" => { - 1901
strings(payload, &["output"]) - 1902
&& payload.get("command").is_none_or(Value::is_string) - 1903
&& payload.get("exit_code").is_none_or(Value::is_i64) - 1904
&& payload.get("duration_ms").is_none_or(&nonnegative) - 1905
} - 1906
"data.grid" => array(payload, "columns").is_some_and(|columns| { - 1907
let keys: std::collections::BTreeSet<_> = - 1908
columns.iter().filter_map(|c| c["key"].as_str()).collect(); - 1909
keys.len() == columns.len() - 1910
&& columns.iter().all(|c| strings(c, &["key", "label"])) - 1911
&& array(payload, "rows").is_some_and(|rows| { - 1912
rows.iter().all(|row| { - 1913
row.as_object().is_some_and(|row| { - 1914
row.iter() - 1915
.all(|(key, value)| keys.contains(key.as_str()) && scalar(value)) - 1916
}) - 1917
}) - 1918
}) - 1919
}), - 1920
"recipe.card" => { - 1921
strings(payload, &["title"]) - 1922
&& payload - 1923
.get("servings") - 1924
.is_none_or(|v| v.as_u64().is_some_and(|v| v > 0 && v <= 10_000)) - 1925
&& array(payload, "ingredients").is_some_and(|items| { - 1926
items.iter().all(|item| { - 1927
item.is_string() - 1928
|| (strings(item, &["name"]) - 1929
&& item.get("amount").is_none_or(&nonnegative) - 1930
&& item.get("unit").is_none_or(Value::is_string)) - 1931
}) - 1932
}) - 1933
&& array(payload, "steps").is_some_and(|steps| { - 1934
steps.iter().all(|step| { - 1935
step.is_string() - 1936
|| (strings(step, &["text"]) - 1937
&& step.get("timer_seconds").is_none_or(|v| { - 1938
v.as_u64().is_some_and(|v| v > 0 && v <= 86_400) - 1939
})) - 1940
}) - 1941
}) - 1942
} - 1943
// The list renderer only reads an array under one of these keys, and - 1944
// only labels an item from one of these fields. A payload that keys - 1945
// its options as, say, `options: [{name, score, pros, cons}]` is not - 1946
// an error anywhere — it simply renders as a raw JSON dump, which is - 1947
// the failure this validator exists to make loud. - 1948
"decision" | "decision_analysis" => [ - 1949
"choices", - 1950
"items", - 1951
"steps", - 1952
"milestones", - 1953
"slots", - 1954
"agenda", - 1955
"tasks", - 1956
"questions", - 1957
"qa", - 1958
"entries", - 1959
] - 1960
.iter() - 1961
.find_map(|key| array(payload, key)) - 1962
.is_some_and(|items| { - 1963
items.iter().all(|item| { - 1964
item.is_string() - 1965
|| item.is_number() - 1966
|| [ - 1967
"label", "title", "name", "question", "task", "text", "choice", "activity", - 1968
] - 1969
.iter() - 1970
.any(|field| item[*field].is_string()) - 1971
}) - 1972
}), - 1973
"plan.timeline" => { - 1974
strings(payload, &["title"]) - 1975
&& array(payload, "items").is_some_and(|items| { - 1976
items.iter().all(|item| { - 1977
strings(item, &["label"]) - 1978
&& item.get("detail").is_none_or(Value::is_string) - 1979
&& item.get("status").is_none_or(Value::is_string) - 1980
&& item.get("time").is_none_or(Value::is_string) - 1981
&& item.get("options").is_none_or(|options| { - 1982
options.as_array().is_some_and(|options| { - 1983
options.iter().all(|option| { - 1984
strings(option, &["label"]) - 1985
&& option.get("detail").is_none_or(Value::is_string) - 1986
&& option.get("facts").is_none_or(|facts| { - 1987
facts.as_array().is_some_and(|facts| { - 1988
facts.iter().all(Value::is_string) - 1989
}) - 1990
}) - 1991
}) - 1992
}) - 1993
}) - 1994
}) - 1995
}) - 1996
} - 1997
_ => return Ok(()), - 1998
}; - 1999
if valid { - 2000
Ok(()) - 2001
} else { - 2002
Err(SkillError::InvalidPayload(format!( - 2003
"invalid {semantic_type} payload shape or values" - 2004
))) - 2005
} - 2006
} - 2007
- 2008
/// Validate the optional, semantic context shared by typed outputs. The - 2009
/// context is data-driven so temporal, coding, research, and data skills can - 2010
/// use the same evidence boundary without the renderer inferring meaning from - 2011
/// incidental payload field names. - 2012
fn validate_context(object: &serde_json::Map<String, Value>) -> Result<(), SkillError> { - 2013
let Some(context) = object.get("context") else { - 2014
return Ok(()); - 2015
}; - 2016
let Some(context) = context.as_object() else { - 2017
return Err(SkillError::InvalidPayload( - 2018
"context must be an object".into(), - 2019
)); - 2020
}; - 2021
for key in ["domain", "as_of", "comparison_basis"] { - 2022
if context.get(key).is_some_and(|value| !value.is_string()) { - 2023
return Err(SkillError::InvalidPayload(format!( - 2024
"context.{key} must be a string" - 2025
))); - 2026
} - 2027
} - 2028
if let Some(period) = context.get("period") { - 2029
let Some(period) = period.as_object() else { - 2030
return Err(SkillError::InvalidPayload( - 2031
"context.period must be an object".into(), - 2032
)); - 2033
}; - 2034
for key in ["start", "end", "timezone"] { - 2035
if !period.get(key).is_some_and(Value::is_string) { - 2036
return Err(SkillError::InvalidPayload(format!( - 2037
"context.period.{key} is required" - 2038
))); - 2039
} - 2040
} - 2041
} - 2042
if let Some(evidence) = context.get("evidence") { - 2043
let Some(evidence) = evidence.as_array() else { - 2044
return Err(SkillError::InvalidPayload( - 2045
"context.evidence must be an array".into(), - 2046
)); - 2047
}; - 2048
if evidence.iter().any(|item| { - 2049
!item.is_object() - 2050
|| !item.get("id").is_some_and(Value::is_string) - 2051
|| !item.get("kind").is_some_and(Value::is_string) - 2052
}) { - 2053
return Err(SkillError::InvalidPayload( - 2054
"each context.evidence item requires string id and kind".into(), - 2055
)); - 2056
} - 2057
} - 2058
if context.get("comparison_basis").is_some() && context.get("period").is_none() { - 2059
return Err(SkillError::InvalidPayload( - 2060
"context.comparison_basis requires context.period".into(), - 2061
)); - 2062
} - 2063
Ok(()) - 2064
} - 2065
- 2066
#[cfg(test)] - 2067
mod tests { - 2068
#![allow(clippy::expect_used, clippy::unwrap_used)] - 2069
- 2070
use super::*; - 2071
- 2072
#[test] - 2073
fn registry_validates_payload_and_capabilities() { - 2074
let mut registry = SkillRegistry::default(); - 2075
let mut renderers = BTreeMap::new(); - 2076
renderers.insert( - 2077
"desktop".into(), - 2078
RendererBinding { - 2079
renderer: "native:chart".into(), - 2080
interactive: true, - 2081
requires: vec!["charts".into()], - 2082
}, - 2083
); - 2084
assert!( - 2085
registry - 2086
.register(PresentationSkillManifest { - 2087
id: "core".into(), - 2088
version: "1.0.0".into(), - 2089
api: PRESENTATION_SKILL_API.into(), - 2090
provides: vec!["chart".into()], - 2091
renderers, - 2092
schema: None, - 2093
outcome_requirements: Vec::new(), - 2094
}) - 2095
.is_ok() - 2096
); - 2097
let output = StructuredOutput { - 2098
semantic_type: "chart".into(), - 2099
schema_version: crate::PRESENTATION_SCHEMA_VERSION, - 2100
skill_id: "core".into(), - 2101
skill_version: "1.0.0".into(), - 2102
payload: serde_json::json!({"chart_type":"line","series":[],"accessible_summary":"No data"}), - 2103
}; - 2104
assert!( - 2105
registry - 2106
.validate(&output, "desktop", &["charts".into()]) - 2107
.is_ok() - 2108
); - 2109
assert!(matches!( - 2110
registry.validate(&output, "desktop", &[]), - 2111
Err(SkillError::MissingCapability(_)) - 2112
)); - 2113
} - 2114
- 2115
#[test] - 2116
fn every_seed_semantic_type_is_accepted_by_the_registry() { - 2117
// A seed presentation that accepts a semantic type the delivery - 2118
// registry does not declare can never be reached: `validate` rejects - 2119
// the output as `UnknownType` before the compiler ever sees it. This - 2120
// is the drift that left `table` unroutable while a `table` seed and - 2121
// a `table` prompt example both existed. - 2122
let accepted = built_in_semantic_types(); - 2123
for record in vak_presentation::seeds::built_in_seed_pack() { - 2124
for semantic_type in &record.spec.accepts { - 2125
assert!( - 2126
accepted.contains(semantic_type), - 2127
"seed {} accepts `{semantic_type}`, which no skill in the built-in \ - 2128
registry provides — the output would be rejected as UnknownType", - 2129
record.spec.id - 2130
); - 2131
} - 2132
} - 2133
} - 2134
- 2135
#[test] - 2136
fn desktop_renderer_vocabulary_matches_the_delivery_registry() { - 2137
// The delivery registry is the admission boundary and the desktop - 2138
// registry is the rendering boundary. A type present on only one side - 2139
// either gets rejected before delivery or reaches the client without a - 2140
// renderer. Keep this check close to the authoritative server list so - 2141
// `cargo test -p vak-delivery` catches either direction of drift. - 2142
let source = include_str!(concat!( - 2143
env!("CARGO_MANIFEST_DIR"), - 2144
"/../vak-client-ui/src/components/PresentationRenderer.tsx" - 2145
)); - 2146
let registry = source - 2147
.split("const STRUCTURED_RENDERERS:") - 2148
.nth(1) - 2149
.and_then(|tail| tail.split("export const structuredRendererTypes").next()) - 2150
.unwrap_or_default(); - 2151
assert!( - 2152
!registry.is_empty(), - 2153
"could not locate STRUCTURED_RENDERERS in PresentationRenderer.tsx" - 2154
); - 2155
- 2156
let mut rendered: Vec<String> = registry - 2157
.lines() - 2158
.filter_map(|line| { - 2159
let line = line.trim_start(); - 2160
let rest = line.strip_prefix('"')?; - 2161
let (semantic_type, rest) = rest.split_once('"')?; - 2162
rest.trim_start() - 2163
.starts_with(':') - 2164
.then(|| semantic_type.to_owned()) - 2165
}) - 2166
.collect(); - 2167
rendered.sort(); - 2168
rendered.dedup(); - 2169
- 2170
let accepted = built_in_semantic_types(); - 2171
let server_only: Vec<_> = accepted - 2172
.iter() - 2173
.filter(|semantic_type| !rendered.contains(semantic_type)) - 2174
.collect(); - 2175
let client_only: Vec<_> = rendered - 2176
.iter() - 2177
.filter(|semantic_type| !accepted.contains(semantic_type)) - 2178
.collect(); - 2179
assert!( - 2180
server_only.is_empty() && client_only.is_empty(), - 2181
"presentation vocabulary drift: server-only={server_only:?}, client-only={client_only:?}" - 2182
); - 2183
} - 2184
- 2185
#[test] - 2186
fn registry_rejects_ambiguous_semantic_type_ownership() { - 2187
let mut registry = built_in_skill_registry(); - 2188
let result = registry.register(PresentationSkillManifest { - 2189
id: "other-skill".into(), - 2190
version: "1.0.0".into(), - 2191
api: PRESENTATION_SKILL_API.into(), - 2192
provides: vec!["data.grid".into()], - 2193
renderers: BTreeMap::new(), - 2194
schema: None, - 2195
outcome_requirements: Vec::new(), - 2196
}); - 2197
assert!( - 2198
matches!(result, Err(SkillError::InvalidManifest(reason)) if reason.contains("already owned")) - 2199
); - 2200
} - 2201
- 2202
#[test] - 2203
fn recipe_selection_is_deterministic_and_surface_aware() { - 2204
let mut catalog = RecipeCatalog::default(); - 2205
let mut fallback = BTreeMap::new(); - 2206
fallback.insert("desktop".into(), "native:weather".into()); - 2207
assert!( - 2208
catalog - 2209
.register(PresentationRecipe { - 2210
id: "weather.forecast".into(), - 2211
version: "1.0.0".into(), - 2212
owner_skill: None, - 2213
priority: 0, - 2214
match_signals: vec!["temperature".into(), "forecast".into()], - 2215
primary: vec!["metric.group".into()], - 2216
optional: vec!["chart.line".into()], - 2217
fallback, - 2218
surfaces: Vec::new(), - 2219
default_recipe: false, - 2220
requires_typed_output: false, - 2221
typed_output_types: Vec::new(), - 2222
}) - 2223
.is_ok() - 2224
); - 2225
let decision = catalog.choose(&["temperature".into(), "forecast".into()], "desktop"); - 2226
assert!(decision.is_some()); - 2227
if let Some(decision) = decision { - 2228
assert_eq!(decision.recipe_id, "weather.forecast"); - 2229
assert_eq!(decision.matched_signals.len(), 2); - 2230
assert_eq!(decision.disposition, DecisionDisposition::Unresolved); - 2231
} - 2232
} - 2233
- 2234
#[test] - 2235
fn revoked_recipe_owner_is_removed_without_affecting_builtins() { - 2236
let mut catalog = RecipeCatalog::default(); - 2237
catalog - 2238
.register(PresentationRecipe { - 2239
id: "plugin.report".into(), - 2240
version: "1.0.0".into(), - 2241
owner_skill: Some("plugin.report_skill".into()), - 2242
priority: 10, - 2243
match_signals: vec!["report".into()], - 2244
primary: vec!["plugin.report".into()], - 2245
optional: Vec::new(), - 2246
fallback: BTreeMap::from([( - 2247
String::from("desktop"), - 2248
String::from("markdown:native"), - 2249
)]), - 2250
surfaces: vec!["desktop".into()], - 2251
default_recipe: false, - 2252
requires_typed_output: false, - 2253
typed_output_types: Vec::new(), - 2254
}) - 2255
.expect("valid recipe"); - 2256
catalog - 2257
.register(PresentationRecipe { - 2258
id: "builtin.report".into(), - 2259
version: "1.0.0".into(), - 2260
owner_skill: None, - 2261
priority: 0, - 2262
match_signals: vec!["report".into()], - 2263
primary: vec!["report".into()], - 2264
optional: Vec::new(), - 2265
fallback: BTreeMap::from([( - 2266
String::from("desktop"), - 2267
String::from("markdown:native"), - 2268
)]), - 2269
surfaces: vec!["desktop".into()], - 2270
default_recipe: false, - 2271
requires_typed_output: false, - 2272
typed_output_types: Vec::new(), - 2273
}) - 2274
.expect("valid recipe"); - 2275
- 2276
catalog.remove_revoked_skills(&std::collections::BTreeSet::from([String::from( - 2277
"plugin.report_skill", - 2278
)])); - 2279
- 2280
assert_eq!(catalog.recipes.len(), 1); - 2281
assert_eq!(catalog.recipes[0].id, "builtin.report"); - 2282
} - 2283
- 2284
#[test] - 2285
fn planner_keeps_rejected_items_auditable() { - 2286
let mut skills = SkillRegistry::default(); - 2287
let mut renderers = BTreeMap::new(); - 2288
renderers.insert( - 2289
"desktop".into(), - 2290
RendererBinding { - 2291
renderer: "native:chart".into(), - 2292
interactive: false, - 2293
requires: vec!["charts".into()], - 2294
}, - 2295
); - 2296
assert!( - 2297
skills - 2298
.register(PresentationSkillManifest { - 2299
id: "core".into(), - 2300
version: "1.0.0".into(), - 2301
api: PRESENTATION_SKILL_API.into(), - 2302
provides: vec!["chart".into()], - 2303
renderers, - 2304
schema: None, - 2305
outcome_requirements: Vec::new(), - 2306
}) - 2307
.is_ok() - 2308
); - 2309
let mut recipes = RecipeCatalog::default(); - 2310
assert!( - 2311
recipes - 2312
.register(PresentationRecipe { - 2313
id: "answer.basic".into(), - 2314
version: "1.0.0".into(), - 2315
owner_skill: None, - 2316
priority: 0, - 2317
match_signals: vec![], - 2318
primary: vec!["chart".into()], - 2319
optional: vec![], - 2320
fallback: BTreeMap::new(), - 2321
surfaces: vec!["desktop".into()], - 2322
default_recipe: false, - 2323
requires_typed_output: false, - 2324
typed_output_types: Vec::new(), - 2325
}) - 2326
.is_ok() - 2327
); - 2328
let planner = PresentationPlanner { skills, recipes }; - 2329
let plan = planner.plan(&[], "desktop", &[], &[StructuredOutput { - 2330
semantic_type: "chart".into(), schema_version: crate::PRESENTATION_SCHEMA_VERSION, skill_id: "core".into(), skill_version: "1.0.0".into(), - 2331
payload: serde_json::json!({"chart_type":"line","series":[],"accessible_summary":"No data"}), - 2332
}]); - 2333
assert!(plan.accepted.is_empty()); - 2334
assert_eq!(plan.rejected.len(), 1); - 2335
} - 2336
- 2337
#[test] - 2338
fn planner_reports_surface_renderer_bindings_not_recipe_defaults() { - 2339
let output = StructuredOutput { - 2340
semantic_type: "data.grid".into(), - 2341
schema_version: crate::PRESENTATION_SCHEMA_VERSION, - 2342
skill_id: "core".into(), - 2343
skill_version: "1.0.0".into(), - 2344
payload: serde_json::json!({ - 2345
"columns": [{"key": "region", "label": "Region"}], - 2346
"rows": [{"region": "APAC"}] - 2347
}), - 2348
}; - 2349
let planner = PresentationPlanner { - 2350
skills: built_in_skill_registry(), - 2351
recipes: built_in_recipes(), - 2352
}; - 2353
- 2354
let desktop = planner.plan( - 2355
&["table_data".into(), "tabular".into()], - 2356
"desktop", - 2357
&[], - 2358
std::slice::from_ref(&output), - 2359
); - 2360
let desktop_recipe = desktop.recipe.expect("desktop recipe"); - 2361
assert_eq!(desktop_recipe.recipe_id, "data.spreadsheet_grid"); - 2362
assert_eq!(desktop_recipe.renderer, "native:structured"); - 2363
assert_eq!(desktop_recipe.disposition, DecisionDisposition::Native); - 2364
assert_eq!(desktop.renderers.len(), 1); - 2365
assert_eq!(desktop.renderers[0].renderer, "native:structured"); - 2366
- 2367
let terminal = planner.plan( - 2368
&["table_data".into(), "tabular".into()], - 2369
"terminal", - 2370
&[], - 2371
std::slice::from_ref(&output), - 2372
); - 2373
let terminal_recipe = terminal.recipe.expect("terminal recipe"); - 2374
assert_eq!(terminal_recipe.renderer, "builtin:generic"); - 2375
assert_eq!(terminal_recipe.disposition, DecisionDisposition::Fallback); - 2376
assert_eq!(terminal.renderers[0].renderer, "builtin:generic"); - 2377
} - 2378
- 2379
#[test] - 2380
fn link_extractor_deduplicates_and_limits_urls() { - 2381
let items = link_previews_from_text( - 2382
"See https://example.com/a, https://example.com/a and http://example.org", - 2383
); - 2384
assert_eq!(items.len(), 2); - 2385
assert_eq!(items[0].semantic_type, "link.preview"); - 2386
} - 2387
- 2388
#[test] - 2389
fn structured_extraction_is_domain_neutral_and_strict() { - 2390
let text = "before\n```vak\n{\"semantic_type\":\"metric\",\"payload\":{\"label\":\"Temperature\",\"value\":25,\"unit\":\"C\"}}\n```\nafter"; - 2391
let outputs = structured_outputs_from_text(text); - 2392
assert_eq!(outputs.len(), 1); - 2393
assert!(structured_outputs_from_text("```vak\nnot-json\n```").is_empty()); - 2394
- 2395
let unfenced = "Vak\n{\"semantic_type\":\"metric\",\"payload\":{\"label\":\"Temperature\",\"value\":25,\"unit\":\"C\"}}\n\nProse."; - 2396
let unfenced_outputs = structured_outputs_from_text(unfenced); - 2397
assert_eq!(unfenced_outputs.len(), 1); - 2398
assert_eq!(unfenced_outputs[0].semantic_type, "metric"); - 2399
} - 2400
- 2401
#[test] - 2402
fn semantic_context_requires_complete_periods_and_evidence_shape() { - 2403
let valid = r#"{"semantic_type":"metric","payload":{"label":"Close","value":10,"context":{"domain":"market","as_of":"2026-09-04T16:00:00+05:30","period":{"start":"2026-08-31","end":"2026-09-04","timezone":"Asia/Kolkata"},"comparison_basis":"prior_close_to_period_close","evidence":[{"id":"source-1","kind":"cited"}]}}}"#; - 2404
assert_eq!(structured_outputs_from_text(valid).len(), 1); - 2405
- 2406
let missing_period_end = r#"{"semantic_type":"metric","payload":{"label":"Close","value":10,"context":{"comparison_basis":"weekly" ,"period":{"start":"2026-08-31","timezone":"Asia/Kolkata"}}}}"#; - 2407
assert!(structured_outputs_from_text(missing_period_end).is_empty()); - 2408
- 2409
let invalid_evidence = r#"{"semantic_type":"metric","payload":{"label":"Close","value":10,"context":{"evidence":[{"id":"source-1"}]}}}"#; - 2410
assert!(structured_outputs_from_text(invalid_evidence).is_empty()); - 2411
} - 2412
- 2413
/// A tool result is almost never Markdown — it's a bare JSON envelope. - 2414
/// One structural rule ("is the whole text a valid envelope?") has to - 2415
/// serve every persona's tools without knowing any of them by name: - 2416
/// a general user's weather lookup, a developer's CI runner, a knowledge - 2417
/// worker's research aggregator, and a data analyst's dataset query all - 2418
/// go through the identical unnamed code path. - 2419
#[test] - 2420
fn bare_json_tool_results_are_recognized_without_a_fence_or_any_tool_name() { - 2421
// General user — a weather tool's raw JSON reply. - 2422
let weather = structured_outputs_from_text( - 2423
r#"{"semantic_type":"metric","payload":{"label":"Temperature","value":25,"unit":"C"}}"#, - 2424
); - 2425
assert_eq!(weather.len(), 1); - 2426
assert_eq!(weather[0].semantic_type, "metric"); - 2427
- 2428
// Developer — a CI/test runner's raw JSON reply. - 2429
let ci = structured_outputs_from_text( - 2430
r#"{"semantic_type":"test.report","payload":{"tests":[{"name":"it_compiles","status":"passed"}],"total":1,"passed":1,"failed":0,"skipped":0}}"#, - 2431
); - 2432
assert_eq!(ci.len(), 1); - 2433
assert_eq!(ci[0].semantic_type, "test.report"); - 2434
- 2435
// Knowledge worker — a research/aggregation tool's raw JSON reply. - 2436
let research = structured_outputs_from_text( - 2437
r#"{"semantic_type":"research.synthesis","payload":{"sources":[{"title":"Report","url":"https://example.com"}],"takeaways":[{"text":"Adoption is rising","citation_indices":[1]}]}}"#, - 2438
); - 2439
assert_eq!(research.len(), 1); - 2440
assert_eq!(research[0].semantic_type, "research.synthesis"); - 2441
- 2442
// Data analyst — a query/BI tool's raw JSON reply. - 2443
let grid = structured_outputs_from_text( - 2444
r#"{"semantic_type":"data.grid","payload":{"columns":[{"key":"region","label":"Region"}],"rows":[{"region":"APAC"}]}}"#, - 2445
); - 2446
assert_eq!(grid.len(), 1); - 2447
assert_eq!(grid[0].semantic_type, "data.grid"); - 2448
- 2449
// Data/telemetry consumer — a metrics tool's raw JSON reply. - 2450
let chart = structured_outputs_from_text( - 2451
r#"{"semantic_type":"chart","payload":{"chart_type":"line","series":[{"name":"p99","points":[{"x":1,"y":42.0}]}],"accessible_summary":"p99 latency over time"}}"#, - 2452
); - 2453
assert_eq!(chart.len(), 1); - 2454
assert_eq!(chart[0].semantic_type, "chart"); - 2455
let timeline = structured_outputs_from_text( - 2456
r#"{"semantic_type":"plan.timeline","payload":{"title":"Weekend trip","items":[{"label":"Travel","detail":"Train to Jaipur"}]}}"#, - 2457
); - 2458
assert_eq!(timeline.len(), 1); - 2459
assert_eq!(timeline[0].semantic_type, "plan.timeline"); - 2460
- 2461
// A tool that just returns plain prose, or JSON with no declared - 2462
// semantic_type, must not have a type guessed for it. - 2463
assert!(structured_outputs_from_text("It is 25C and sunny in Austin.").is_empty());
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.