- 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()); - 2464
assert!(structured_outputs_from_text(r#"{"temp": 25, "condition": "sunny"}"#).is_empty()); - 2465
assert!( - 2466
structured_outputs_from_text(r#"{"semantic_type":"metric","payload":{"label":"x"}}"#) - 2467
.is_empty() - 2468
); - 2469
} - 2470
- 2471
/// A plan step may carry the alternatives the person chooses between; - 2472
/// they are typed, validated, and spelled out in the text form a channel - 2473
/// gets. - 2474
#[test] - 2475
fn plan_step_options_are_typed_and_reach_the_text_form() { - 2476
let plan = structured_outputs_from_text( - 2477
r#"{"semantic_type":"plan.timeline","payload":{"title":"Saturday","items":[{"label":"Morning","time":"9:00–12:00","detail":"Pancakes, then the park"},{"label":"Afternoon","detail":"Pick one","options":[{"label":"Science museum","detail":"Hands-on exhibits","facts":["20 min away","2 hours"]},{"label":"Botanic garden"}]}]}}"#, - 2478
); - 2479
assert_eq!(plan.len(), 1, "a step with options is a valid plan"); - 2480
let text = structured_markdown(&plan[0]); - 2481
assert!(text.contains("- Morning (9:00–12:00): Pancakes, then the park")); - 2482
assert!( - 2483
text.contains(" - Option: Science museum: Hands-on exhibits · 20 min away, 2 hours") - 2484
); - 2485
assert!(text.contains(" - Option: Botanic garden")); - 2486
- 2487
for malformed in [ - 2488
r#"{"label":"Afternoon","options":"museum or garden"}"#, - 2489
r#"{"label":"Afternoon","options":[{"detail":"no label"}]}"#, - 2490
r#"{"label":"Afternoon","options":[{"label":"Museum","facts":"far"}]}"#, - 2491
r#"{"label":"Afternoon","time":3}"#, - 2492
] { - 2493
let source = format!( - 2494
r#"{{"semantic_type":"plan.timeline","payload":{{"title":"Saturday","items":[{malformed}]}}}}"# - 2495
); - 2496
assert!( - 2497
structured_outputs_from_text(&source).is_empty(), - 2498
"rejected: {malformed}" - 2499
); - 2500
} - 2501
} - 2502
- 2503
#[test] - 2504
fn universal_recipes_selection_from_signals_and_context() { - 2505
let catalog = built_in_recipes(); - 2506
- 2507
// 1. Research synthesis - 2508
let research_text = "Key takeaways from our findings:\n1. Rust is fast.\nSources:\nhttps://example.com/rust"; - 2509
let sigs = signals_from_text(research_text); - 2510
let decision = catalog - 2511
.choose(&sigs, "desktop") - 2512
.expect("research synthesis decision"); - 2513
assert_eq!(decision.recipe_id, "research.synthesis"); - 2514
- 2515
// 2. Diff inspector - 2516
let diff_text = "Files changed:\n```diff\n@@ -1,2 +1,3 @@\n+added\n```"; - 2517
let sigs = signals_from_text(diff_text); - 2518
let decision = catalog - 2519
.choose(&sigs, "desktop") - 2520
.expect("diff inspector decision"); - 2521
assert_eq!(decision.recipe_id, "coding.diff_inspector"); - 2522
- 2523
// 3. Test report - 2524
let test_text = "Test suite executed: 42 passed, 0 failures."; - 2525
let sigs = signals_from_text(test_text); - 2526
assert!(catalog.choose(&sigs, "desktop").is_none()); - 2527
- 2528
// 4. Terminal session from bash tool context - 2529
let cmd = serde_json::json!({"command": "docker ps -a"}); - 2530
let ctx = SignalContext { - 2531
text: "Container status: exit 0", - 2532
tool_name: Some("bash"), - 2533
tool_input: Some(&cmd), - 2534
tool_output: Some("CONTAINER ID IMAGE STATUS"), - 2535
is_error: false, - 2536
domains: &[], - 2537
}; - 2538
let sigs = signals_from_context(&ctx); - 2539
let decision = catalog.choose(&sigs, "desktop").expect("terminal decision"); - 2540
assert_eq!(decision.recipe_id, "terminal.session"); - 2541
- 2542
// 5. Culinary recipe - 2543
let recipe_text = - 2544
"Recipe for Salmon:\nPrep time: 10m\nIngredients:\n- 2 fillets\n- 2 tbsp olive oil"; - 2545
let sigs = signals_from_text(recipe_text); - 2546
let decision = catalog.choose(&sigs, "desktop").expect("recipe decision"); - 2547
assert_eq!(decision.recipe_id, "lifestyle.culinary_recipe"); - 2548
- 2549
// 6. Data spreadsheet grid - 2550
let table_text = "Dataset breakdown:\n| MRR | Growth |\n| --- | --- |\n| $10k | +20% |"; - 2551
let sigs = signals_from_text(table_text); - 2552
let decision = catalog.choose(&sigs, "desktop").expect("grid decision"); - 2553
assert_eq!(decision.recipe_id, "data.spreadsheet_grid"); - 2554
- 2555
// Incidental substrings in news prose must not select a coding report. - 2556
let news = "Protests continued while the market surpassed expectations."; - 2557
let sigs = signals_from_text(news); - 2558
assert!(!sigs.iter().any(|signal| signal == "tests")); - 2559
assert!(!sigs.iter().any(|signal| signal == "pass_fail")); - 2560
assert_ne!( - 2561
catalog.choose(&sigs, "desktop").map(|d| d.recipe_id), - 2562
Some("coding.test_report".into()) - 2563
); - 2564
- 2565
// 7. Multi chart - 2566
let chart_text = - 2567
"Telemetry metrics chart showing p99 latency trend and req/sec throughput."; - 2568
let sigs = signals_from_text(chart_text); - 2569
let decision = catalog.choose(&sigs, "desktop").expect("chart decision"); - 2570
assert_eq!(decision.recipe_id, "data.multi_chart"); - 2571
} - 2572
- 2573
#[test] - 2574
fn accepted_plugin_output_surfaces_declared_outcome_requirements() { - 2575
let mut skills = SkillRegistry::default(); - 2576
let mut renderers = BTreeMap::new(); - 2577
renderers.insert( - 2578
"desktop".into(), - 2579
RendererBinding { - 2580
renderer: "native:custom".into(), - 2581
interactive: false, - 2582
requires: Vec::new(), - 2583
}, - 2584
); - 2585
assert!( - 2586
skills - 2587
.register(PresentationSkillManifest { - 2588
id: "planning".into(), - 2589
version: "1.0.0".into(), - 2590
api: PRESENTATION_SKILL_API.into(), - 2591
provides: vec!["plan".into()], - 2592
renderers, - 2593
schema: None, - 2594
outcome_requirements: vec![OutcomeRequirementDeclaration { - 2595
id: "plan-next-steps".into(), - 2596
kind: "constraint".into(), - 2597
description: "include next steps".into(), - 2598
importance: "must".into(), - 2599
}], - 2600
}) - 2601
.is_ok() - 2602
); - 2603
let planner = PresentationPlanner { - 2604
skills, - 2605
recipes: RecipeCatalog::default(), - 2606
}; - 2607
let plan = planner.plan( - 2608
&[], - 2609
"desktop", - 2610
&[], - 2611
&[StructuredOutput { - 2612
semantic_type: "plan".into(), - 2613
schema_version: crate::PRESENTATION_SCHEMA_VERSION, - 2614
skill_id: "planning".into(), - 2615
skill_version: "1.0.0".into(), - 2616
payload: serde_json::json!({"steps": []}), - 2617
}], - 2618
); - 2619
assert_eq!(plan.outcome_requirements.len(), 1); - 2620
assert_eq!(plan.outcome_requirements[0].id, "plan-next-steps"); - 2621
let mut outcome = vak_intent::OutcomeSpec::from_reading( - 2622
"make a plan", - 2623
&vak_intent::Reading::general(), - 2624
1, - 2625
); - 2626
assert!(plan.merge_outcome_requirements(&mut outcome).is_empty()); - 2627
assert!( - 2628
outcome - 2629
.requirements - 2630
.iter() - 2631
.any(|requirement| requirement.id == "plan-next-steps") - 2632
); - 2633
} - 2634
} - 2635
- 2636
#[cfg(test)] - 2637
mod structural_signal_tests { - 2638
use super::signals_from_text; - 2639
- 2640
fn has(text: &str, signal: &str) -> bool { - 2641
signals_from_text(text).iter().any(|s| s == signal) - 2642
} - 2643
- 2644
#[test] - 2645
fn a_real_markdown_table_is_tabular_even_when_indented_or_unspaced() { - 2646
assert!(has( - 2647
"| Index | Change |\n|---|---|\n| Nifty | -0.22% |\n", - 2648
"tabular" - 2649
)); - 2650
assert!(has( - 2651
"Results:\n\n | a | b |\n | - | - |\n | 1 | 2 |\n", - 2652
"tabular" - 2653
)); - 2654
} - 2655
- 2656
#[test] - 2657
fn table_syntax_inside_a_code_sample_is_not_a_table() { - 2658
let text = "Markdown tables look like this:\n\n```\n| a | b |\n|---|---|\n```\n"; - 2659
assert!( - 2660
!has(text, "tabular"), - 2661
"a table shown as source is not a table" - 2662
); - 2663
assert!(!has("use `|---|` as the separator row", "tabular")); - 2664
} - 2665
- 2666
#[test] - 2667
fn tab_separated_rows_are_tabular_but_one_stray_tab_is_not() { - 2668
assert!(has("name\tqty\napple\t3\npear\t5\n", "tabular")); - 2669
assert!(!has("He paused,\tthen left.", "tabular")); - 2670
} - 2671
- 2672
#[test] - 2673
fn diffs_are_recognised_by_structure() { - 2674
assert!(has("```diff\n- old\n+ new\n```\n", "diff")); - 2675
assert!(has("diff --git a/x b/x\n--- a/x\n+++ b/x\n", "diff")); - 2676
assert!(has("@@ -1,2 +1,3 @@ fn main\n line\n", "diff")); - 2677
assert!( - 2678
!has("the @@ sign and diff of opinions", "diff"), - 2679
"prose mentioning them is not a diff" - 2680
); - 2681
} - 2682
} - 2683
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.