- 465
"decision", - 466
"decision_analysis", - 467
"meal_plan", - 468
], - 469
payload_schema: timeline_payload_schema, - 470
}, - 471
CardShape { - 472
name: "emit_recipe_card", - 473
description: "Emit a recipe card with ingredients and steps.", - 474
semantic_types: &[ - 475
"recipe.card", - 476
"recipe", - 477
"recipe_summary", - 478
"lifestyle.recipe", - 479
"lifestyle.culinary_recipe", - 480
], - 481
payload_schema: recipe_payload_schema, - 482
}, - 483
CardShape { - 484
name: "emit_ui_preview_card", - 485
description: "Emit a preview card for an HTML/UI artifact you wrote to the workspace.", - 486
semantic_types: &["ui.preview"], - 487
payload_schema: ui_preview_payload_schema, - 488
}, - 489
CardShape { - 490
name: "emit_chart_card", - 491
description: "Emit a chart card for a numeric series over time or categories.", - 492
semantic_types: &[ - 493
"chart", - 494
"trend", - 495
"timeseries", - 496
"bar_chart", - 497
"metric_chart", - 498
"comparison_chart", - 499
"telemetry.chart", - 500
], - 501
payload_schema: chart_payload_schema, - 502
}, - 503
CardShape { - 504
name: "emit_media_card", - 505
description: "Emit a link preview or media (image/video/audio) card.", - 506
semantic_types: &["link.preview", "media.image", "media.video", "media.audio"], - 507
payload_schema: media_payload_schema, - 508
}, - 509
CardShape { - 510
name: "emit_metric_card", - 511
description: "Emit a metric card: a single current measurement or a small grid of them (a reading, a price, a KPI, a benchmark number). Prefer it whenever the answer is one value, even if you searched the web to get it.", - 512
semantic_types: &["metric", "telemetry.metric", "weather"], - 513
payload_schema: metric_payload_schema, - 514
}, - 515
]; - 516
- 517
/// Repair payload shapes that `SkillRegistry::validate` checks strictly but - 518
/// that a shared per-*shape* schema can't fully pin down on its own (a - 519
/// handful of the ~84 semantic_types have stricter per-field-name or - 520
/// derived-value requirements than their sibling types in the same shape - 521
/// category). Fixing these here — deterministically, from data the model - 522
/// already gave us — is more reliable than asking a small local model to - 523
/// track field-name synonyms or keep derived counts consistent by hand. - 524
fn normalize_payload(semantic_type: &str, mut payload: Value) -> Value { - 525
match semantic_type { - 526
// `itinerary` validates each item's `title`; the shared timeline - 527
// schema asks the model for `label`. Same data, two field names. - 528
"itinerary" => { - 529
if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) { - 530
for item in items { - 531
if item.get("title").is_none() - 532
&& let Some(label) = item.get("label").cloned() - 533
{ - 534
item["title"] = label; - 535
} - 536
} - 537
} - 538
} - 539
// `plan.timeline` validates a top-level `title`; not required by the - 540
// shared schema since most sibling timeline types don't need one. - 541
"plan.timeline" => { - 542
if payload.get("title").and_then(Value::as_str).is_none() { - 543
payload["title"] = Value::String("Plan".into()); - 544
} - 545
} - 546
// `test.report` validates that any `total`/`passed`/`failed`/`skipped` - 547
// counts the model supplies match the actual `tests` array — so - 548
// derive them instead of trusting the model to keep them in sync. - 549
"test.report" => { - 550
if let Some(tests) = payload.get("tests").and_then(Value::as_array).cloned() { - 551
let count_where = |status: &str| { - 552
tests - 553
.iter() - 554
.filter(|t| t.get("status").and_then(Value::as_str) == Some(status)) - 555
.count() as u64 - 556
}; - 557
payload["total"] = Value::from(tests.len() as u64); - 558
payload["passed"] = Value::from(count_where("passed")); - 559
payload["failed"] = Value::from(count_where("failed")); - 560
payload["skipped"] = Value::from(count_where("skipped")); - 561
} - 562
} - 563
_ => {} - 564
} - 565
payload - 566
} - 567
- 568
/// One representative-but-minimal fixture payload per shape, built to - 569
/// satisfy that shape's `payload_schema` (and, where the schema alone - 570
/// isn't enough, the stricter per-type validators in - 571
/// `vak_delivery::skills::validate_payload`). Every `semantic_type` this - 572
/// shape's tool can emit is then executed with the SAME fixture, to - 573
/// prove the shared schema (plus `normalize_payload` for the couple of - 574
/// known type-specific exceptions) genuinely renders for every type the - 575
/// tool claims to support — not just one hand-picked example. - 576
#[allow(clippy::panic)] - 577
fn fixture_for(shape_name: &str) -> Value { - 578
match shape_name { - 579
"emit_universal_card" => serde_json::json!({"title": "T", "summary": "S"}), - 580
"emit_research_card" => serde_json::json!({ - 581
"sources": [{"title": "Src", "url": "https://example.com"}], - 582
"takeaways": [{"text": "Point", "citation_indices": [1]}] - 583
}), - 584
"emit_diff_card" => serde_json::json!({ - 585
"files": [{"filename": "a.rs", "hunks": "@@ -1 +1 @@", "additions": 1, "deletions": 0}] - 586
}), - 587
"emit_test_report_card" => serde_json::json!({ - 588
"tests": [{"name": "it_works", "status": "passed"}] - 589
}), - 590
"emit_terminal_card" => serde_json::json!({"command": "ls", "output": "a.rs"}), - 591
"emit_table_card" => serde_json::json!({ - 592
"columns": [{"key": "name", "label": "Name"}], - 593
"rows": [{"name": "Alice"}] - 594
}), - 595
"emit_timeline_card" => serde_json::json!({ - 596
"title": "T", - 597
"items": [{"label": "Step 1", "detail": "d"}] - 598
}), - 599
"emit_recipe_card" => serde_json::json!({ - 600
"title": "Soup", - 601
"ingredients": [{"name": "Water"}], - 602
"steps": [{"text": "Boil"}] - 603
}), - 604
"emit_ui_preview_card" => serde_json::json!({"title": "Preview"}), - 605
"emit_chart_card" => serde_json::json!({ - 606
"chart_type": "line", - 607
"accessible_summary": "flat", - 608
"series": [{"name": "s1", "points": [{"x": 1, "y": 2.0}]}] - 609
}), - 610
"emit_media_card" => serde_json::json!({"url": "https://example.com", "title": "Link"}), - 611
"emit_metric_card" => { - 612
serde_json::json!({"label": "Uptime", "value": 99.9, "unit": "%"}) - 613
} - 614
other => panic!("no fixture defined for shape {other} — add one"), - 615
} - 616
} - 617
- 618
/// A shape's schema can be a `oneOf` covering several distinct payload - 619
/// shapes for different semantic_types within it (e.g. `emit_media_card`: - 620
/// `link.preview` wants url+title, `media.*` wants source+media_type+alt). - 621
/// Override the shared fixture for those specific types. - 622
fn fixture_override(semantic_type: &str) -> Option<Value> { - 623
match semantic_type { - 624
"media.image" => Some( - 625
serde_json::json!({"source": "https://example.com/a.png", "media_type": "image", "alt": "a"}), - 626
), - 627
"media.video" => Some( - 628
serde_json::json!({"source": "https://example.com/a.mp4", "media_type": "video", "alt": "a"}), - 629
), - 630
"media.audio" => Some( - 631
serde_json::json!({"source": "https://example.com/a.mp3", "media_type": "audio", "alt": "a"}), - 632
), - 633
_ => None, - 634
} - 635
} - 636
- 637
/// Every `(tool, semantic_type, payload)` the tools claim to support, with a - 638
/// schema-valid payload — the single source for conformance tests here and in - 639
/// vak-server (which checks the full call → ledger → projection path). - 640
#[doc(hidden)] - 641
pub fn conformance_cases() -> Vec<(&'static str, &'static str, Value)> { - 642
let mut out = Vec::new(); - 643
for shape in SHAPES { - 644
for &semantic_type in shape.semantic_types { - 645
let payload = - 646
fixture_override(semantic_type).unwrap_or_else(|| fixture_for(shape.name)); - 647
out.push((shape.name, semantic_type, payload)); - 648
} - 649
} - 650
out - 651
} - 652
- 653
pub struct EmitCardTool { - 654
shape: &'static CardShape, - 655
} - 656
- 657
impl EmitCardTool { - 658
/// One `EmitCardTool` per registered shape category — call this once - 659
/// per entry in `SHAPES` when assembling the tool list for a turn. - 660
pub fn all() -> Vec<Self> { - 661
SHAPES.iter().map(|shape| EmitCardTool { shape }).collect() - 662
} - 663
} - 664
- 665
#[async_trait] - 666
impl Tool for EmitCardTool { - 667
fn name(&self) -> &str { - 668
self.shape.name - 669
} - 670
- 671
fn description(&self) -> &str { - 672
self.shape.description - 673
} - 674
- 675
fn schema(&self) -> Value { - 676
serde_json::json!({ - 677
"type": "object", - 678
"properties": { - 679
"semantic_type": { - 680
"type": "string", - 681
"enum": self.shape.semantic_types, - 682
"description": "Which of this shape's card types this is." - 683
}, - 684
"payload": (self.shape.payload_schema)() - 685
}, - 686
"required": ["semantic_type", "payload"] - 687
}) - 688
} - 689
- 690
fn presents_cards(&self) -> bool { - 691
true - 692
} - 693
- 694
async fn execute(&self, args: &Value, _ctx: &ToolContext) -> ToolOutput { - 695
match validate_call(self.shape, args, &vak_delivery::built_in_skill_registry()) { - 696
Ok(output) => ToolOutput::ok(format!( - 697
"Card displayed to the user ({}). It is already on screen. Leave final text empty \ - 698
if the card answers fully. Only additional information will be shown: begin it \ - 699
with `Note:` and do not repeat card data or write a `vak` fence.", - 700
output.semantic_type - 701
)), - 702
Err(reason) => ToolOutput::error(format!( - 703
"Card not displayed: {reason}. Fix the arguments and call {} again.", - 704
self.shape.name - 705
)), - 706
} - 707
} - 708
} - 709
- 710
fn validate_call( - 711
shape: &CardShape, - 712
args: &Value, - 713
skills: &vak_delivery::SkillRegistry, - 714
) -> Result<vak_delivery::StructuredOutput, String> { - 715
let Some(semantic_type) = args.get("semantic_type").and_then(Value::as_str) else { - 716
return Err("missing or non-string `semantic_type`".into()); - 717
}; - 718
if !shape.semantic_types.contains(&semantic_type) { - 719
return Err(format!( - 720
"`{semantic_type}` is not one of this tool's types {:?}; use the matching emit_*_card tool", - 721
shape.semantic_types - 722
)); - 723
} - 724
let Some(payload) = args.get("payload") else { - 725
return Err("missing `payload`".into()); - 726
}; - 727
let envelope = serde_json::json!({ - 728
"semantic_type": semantic_type, - 729
"payload": normalize_payload(semantic_type, payload.clone()), - 730
}); - 731
vak_delivery::parse_fragment_with(&envelope.to_string(), skills).map_err(|e| e.to_string()) - 732
} - 733
- 734
/// The `emit_*_card` tool that carries `semantic_type`, if any. - 735
pub fn emit_tool_for(semantic_type: &str) -> Option<&'static str> { - 736
SHAPES - 737
.iter() - 738
.find(|shape| shape.semantic_types.contains(&semantic_type)) - 739
.map(|shape| shape.name) - 740
} - 741
- 742
/// The one-shot nudge for an answer that reads as something the app presents - 743
/// as a card but was written as prose. Driven entirely by the app's own signal - 744
/// and recipe detection (`signals_from_text` → `RecipeCatalog::intended_outputs`) - 745
/// — no per-type rules here — and only names a tool that was actually offered. - 746
pub fn presentation_check_nudge( - 747
text: &str, - 748
offered_tools: &[String], - 749
recipes: &vak_delivery::RecipeCatalog, - 750
) -> Option<vak_agent::PresentationNudge> { - 751
let signals = vak_delivery::signals_from_text(text); - 752
let intended = recipes.intended_outputs(&signals, "desktop")?; - 753
let (semantic_type, tool) = intended.primary_types.iter().find_map(|semantic_type| { - 754
let tool = emit_tool_for(semantic_type)?; - 755
offered_tools - 756
.iter() - 757
.any(|offered| offered == tool) - 758
.then_some((semantic_type.as_str(), tool)) - 759
})?; - 760
Some(vak_agent::PresentationNudge { - 761
tool: tool.to_string(), - 762
text: format!( - 763
"[presentation-check]: Your answer reads as `{}` (signals: {}), which the app presents \ - 764
as a card, but no card was emitted. If a card fits, call `{tool}` with \ - 765
semantic_type `{semantic_type}` and this content, and do not restate the data as text \ - 766
(any text after it is shown only if it begins with `Note:`). If a card genuinely does \ - 767
not fit, resend your answer unchanged.", - 768
intended.recipe_id, - 769
intended.matched_signals.join(", ") - 770
), - 771
}) - 772
} - 773
- 774
/// The card tools a request itself reads as, from the app's own signal and - 775
/// recipe detection over the request text — the same detection the - 776
/// presentation check runs over the answer. These are loaded for the turn; - 777
/// every other card tool is deferred until the check or `find_tools` asks. - 778
pub fn predicted_card_tools( - 779
request: &str, - 780
recipes: &vak_delivery::RecipeCatalog, - 781
) -> std::collections::BTreeSet<String> { - 782
let signals = vak_delivery::signals_from_text(request); - 783
recipes - 784
.intended_outputs(&signals, "desktop") - 785
.map(|intended| { - 786
intended - 787
.primary_types - 788
.iter() - 789
.filter_map(|semantic_type| emit_tool_for(semantic_type)) - 790
.map(str::to_string) - 791
.collect() - 792
}) - 793
.unwrap_or_default() - 794
} - 795
- 796
/// Names of every tool that declares `presents_cards()`, for the permission - 797
/// engine: a card is Vak's own display channel and needs no approval. - 798
pub fn presenting_tool_names() -> Vec<String> { - 799
EmitCardTool::all() - 800
.iter() - 801
.filter(|tool| tool.presents_cards()) - 802
.map(|tool| tool.name().to_string()) - 803
.collect() - 804
} - 805
- 806
/// Whether `name` is one of the `emit_*_card` tools. - 807
pub fn is_card_tool(name: &str) -> bool { - 808
SHAPES.iter().any(|shape| shape.name == name) - 809
} - 810
- 811
/// Rebuilds an `emit_*_card` call's validated output from the call's own - 812
/// arguments — the ledger records these untruncated, so nothing depends on - 813
/// the tool result text (which the tool framework line-truncates at ~2000 - 814
/// characters, silently destroying any larger card's JSON). Private: the - 815
/// only consumers are this module's own conformance tests and - 816
/// `presentation_info`, which turns this into a `Presentation` ledger entry - 817
/// at the moment a card validates (docs/design/68-context-engine.md §10). - 818
/// `vak-server`'s projection used to call a public version of this - 819
/// (`card_output_from_call`) to rebuild the card for display on every - 820
/// snapshot; it now reads the written `Presentation` entry instead, so - 821
/// nothing outside this crate needs to re-validate a call's arguments. - 822
fn rebuild_call( - 823
name: &str, - 824
input: &Value, - 825
skills: &vak_delivery::SkillRegistry, - 826
) -> Option<vak_delivery::StructuredOutput> { - 827
let shape = SHAPES.iter().find(|shape| shape.name == name)?; - 828
validate_call(shape, input, skills).ok() - 829
} - 830
- 831
/// Everything needed to write a `PresentationRecord` for a call that just - 832
/// validated: the canonical payload, the schema-driven title and identity - 833
/// digest, and which skill/version/schema owns the type. `vak-agent`'s - 834
/// tool-execution path has no session-log access (AGENTS.md invariant 14: - 835
/// tools cross a broker boundary), so this is exposed through - 836
/// `AgentConfig::presentation_rebuild`, a closure `Core` installs — the - 837
/// agent loop stays free of card-shape knowledge and calls this indirectly. - 838
pub struct PresentationInfo { - 839
pub semantic_type: String, - 840
pub skill_id: String, - 841
pub skill_version: String, - 842
pub schema_version: u32, - 843
/// Canonical (key-sorted) form — see `vak_session::types::canonicalize_json`. - 844
pub payload: Value, - 845
pub title: String, - 846
pub identity_digest: String, - 847
} - 848
- 849
/// Validates an `emit_*_card` call and returns everything needed to write - 850
/// its `Presentation` ledger entry. `None` when the call does not validate - 851
/// (the tool's own `execute()` already rejected it in that case, so this is - 852
/// only ever called for a call that already succeeded — see - 853
/// `AgentConfig::presentation_rebuild`'s call site in `Core`). - 854
pub fn presentation_info( - 855
name: &str, - 856
input: &Value, - 857
skills: &vak_delivery::SkillRegistry, - 858
) -> Option<PresentationInfo> { - 859
let output = rebuild_call(name, input, skills)?; - 860
let payload = vak_session::types::canonicalize_json(&output.payload); - 861
let title = title_for(&output.semantic_type, &payload); - 862
let identity_digest = identity_digest(&output.semantic_type, &payload); - 863
Some(PresentationInfo { - 864
semantic_type: output.semantic_type, - 865
skill_id: output.skill_id, - 866
skill_version: output.skill_version, - 867
schema_version: u32::from(output.schema_version), - 868
payload, - 869
title, - 870
identity_digest, - 871
}) - 872
} - 873
- 874
/// Title fallback for a card whose payload has no `title` field (only the - 875
/// metric shape lacks one; every other shape's schema asks the model for - 876
/// one). - 877
fn title_for(semantic_type: &str, payload: &Value) -> String { - 878
if let Some(title) = payload.get("title").and_then(Value::as_str) - 879
&& !title.trim().is_empty() - 880
{ - 881
return title.to_string(); - 882
} - 883
payload - 884
.get("label") - 885
.and_then(Value::as_str) - 886
.or_else(|| payload.get("location").and_then(Value::as_str)) - 887
.unwrap_or(semantic_type) - 888
.to_string() - 889
} - 890
- 891
fn compact_scalar(value: &Value) -> String { - 892
match value { - 893
Value::String(s) => s.clone(), - 894
other => other.to_string(), - 895
} - 896
} - 897
- 898
fn canonical_compact(payload: &Value) -> String { - 899
let canonical = vak_session::types::canonicalize_json(payload); - 900
serde_json::to_string(&canonical).unwrap_or_default() - 901
} - 902
- 903
fn research_digest(payload: &Value) -> String { - 904
let takeaways: Vec<String> = payload - 905
.get("takeaways") - 906
.and_then(Value::as_array) - 907
.map(|items| { - 908
items - 909
.iter() - 910
.filter_map(|t| t.get("text").and_then(Value::as_str)) - 911
.map(str::to_string) - 912
.collect() - 913
}) - 914
.unwrap_or_default(); - 915
let sources: Vec<String> = payload - 916
.get("sources") - 917
.and_then(Value::as_array) - 918
.map(|items| { - 919
items - 920
.iter() - 921
.map(|s| { - 922
let title = s.get("title").and_then(Value::as_str).unwrap_or(""); - 923
let url = s.get("url").and_then(Value::as_str).unwrap_or(""); - 924
format!("{title} ({url})") - 925
}) - 926
.collect() - 927
}) - 928
.unwrap_or_default(); - 929
format!( - 930
"takeaways: {} | sources: {}", - 931
takeaways.join(" ~ "), - 932
sources.join(", ") - 933
) - 934
} - 935
- 936
fn table_digest(payload: &Value) -> String { - 937
let title = payload.get("title").and_then(Value::as_str).unwrap_or(""); - 938
let columns: Vec<String> = payload - 939
.get("columns") - 940
.and_then(Value::as_array) - 941
.map(|items| { - 942
items - 943
.iter() - 944
.filter_map(|c| c.get("key").and_then(Value::as_str)) - 945
.map(str::to_string) - 946
.collect() - 947
}) - 948
.unwrap_or_default(); - 949
let rows = payload.get("rows").and_then(Value::as_array); - 950
let row_count = rows.map(Vec::len).unwrap_or(0); - 951
let first_row = rows.and_then(|r| r.first()).cloned().unwrap_or(Value::Null); - 952
format!( - 953
"title: {title} | columns: {} | rows: {row_count} | first: {}", - 954
columns.join(","), - 955
canonical_compact(&first_row) - 956
) - 957
} - 958
- 959
fn chart_digest(payload: &Value) -> String { - 960
let title = payload.get("title").and_then(Value::as_str).unwrap_or(""); - 961
let summary = payload - 962
.get("accessible_summary") - 963
.and_then(Value::as_str) - 964
.unwrap_or(""); - 965
let series: Vec<String> = payload - 966
.get("series") - 967
.and_then(Value::as_array) - 968
.map(|items| { - 969
items - 970
.iter() - 971
.map(|s| { - 972
let name = s.get("name").and_then(Value::as_str).unwrap_or(""); - 973
let points = s - 974
.get("points") - 975
.and_then(Value::as_array) - 976
.map(Vec::len) - 977
.unwrap_or(0); - 978
format!("{name}({points})") - 979
}) - 980
.collect() - 981
}) - 982
.unwrap_or_default(); - 983
format!( - 984
"title: {title} | series: {} | summary: {summary}", - 985
series.join(",") - 986
) - 987
} - 988
- 989
fn entity_digest(payload: &Value) -> String { - 990
let title = payload.get("title").and_then(Value::as_str).unwrap_or(""); - 991
let entity_type = payload.get("type").and_then(Value::as_str).unwrap_or(""); - 992
let mut fields: Vec<String> = payload - 993
.as_object() - 994
.map(|obj| { - 995
obj.iter() - 996
.filter(|(key, _)| key.as_str() != "title" && key.as_str() != "type") - 997
.map(|(key, value)| format!("{key}={}", compact_scalar(value))) - 998
.collect() - 999
}) - 1000
.unwrap_or_default(); - 1001
fields.sort(); - 1002
format!( - 1003
"title: {title} | type: {entity_type} | fields: {}", - 1004
fields.join(",") - 1005
) - 1006
} - 1007
- 1008
fn items_digest(payload: &Value) -> String { - 1009
let title = payload.get("title").and_then(Value::as_str).unwrap_or(""); - 1010
let labels: Vec<String> = payload - 1011
.get("items") - 1012
.and_then(Value::as_array) - 1013
.map(|items| { - 1014
items - 1015
.iter() - 1016
.filter_map(|item| { - 1017
item.get("label") - 1018
.or_else(|| item.get("title")) - 1019
.and_then(Value::as_str) - 1020
.map(str::to_string) - 1021
}) - 1022
.collect() - 1023
}) - 1024
.unwrap_or_default(); - 1025
format!("title: {title} | items: {}", labels.join(",")) - 1026
} - 1027
- 1028
fn default_digest(semantic_type: &str, payload: &Value) -> String { - 1029
let title = payload - 1030
.get("title") - 1031
.and_then(Value::as_str) - 1032
.unwrap_or(semantic_type); - 1033
let mut keys: Vec<&str> = payload - 1034
.as_object() - 1035
.map(|obj| obj.keys().map(String::as_str).collect()) - 1036
.unwrap_or_default(); - 1037
keys.sort(); - 1038
format!("title: {title} | keys: {}", keys.join(",")) - 1039
} - 1040
- 1041
/// Schema-driven identity digest (docs/design/68-context-engine.md §10): - 1042
/// which fields make a presentation distinguishable from another of the - 1043
/// same `semantic_type`. Dispatches by shape (the same grouping `SHAPES` - 1044
/// already uses — a `table`-shaped type and a `chart`-shaped type get - 1045
/// digested the same way as their siblings), with `entity` and the - 1046
/// checklist/timeline/itinerary family called out explicitly per the - 1047
/// design. Never a character-count truncation: each branch names the - 1048
/// fields that carry identity for its shape instead of cutting the payload - 1049
/// short. - 1050
pub fn identity_digest(semantic_type: &str, payload: &Value) -> String { - 1051
match emit_tool_for(semantic_type) { - 1052
Some("emit_metric_card") => canonical_compact(payload), - 1053
Some("emit_research_card") => research_digest(payload), - 1054
Some("emit_table_card") => table_digest(payload), - 1055
Some("emit_chart_card") => chart_digest(payload), - 1056
Some("emit_timeline_card") => items_digest(payload), - 1057
_ if semantic_type == "entity" => entity_digest(payload), - 1058
_ => default_digest(semantic_type, payload), - 1059
} - 1060
} - 1061
- 1062
#[cfg(test)] - 1063
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 1064
mod tests { - 1065
use super::*; - 1066
- 1067
/// Real trigger: "Vak wants to use emit_metric_card — this needs your - 1068
/// approval" under a card that had already rendered. Cards are Vak's own - 1069
/// display channel, so every shape is open in every mode, through the - 1070
/// engine `Core` builds — the one the capability preflight reads too. - 1071
#[test] - 1072
fn every_card_tool_is_allowed_in_every_mode_without_a_rule() { - 1073
let dir = tempfile::tempdir().unwrap(); - 1074
let engine = crate::build_engine_with(&vak_config::Config::default(), &[]).unwrap(); - 1075
let args = serde_json::json!({}); - 1076
assert!(!SHAPES.is_empty()); - 1077
for shape in SHAPES { - 1078
for mode in [ - 1079
vak_permission::Mode::ReadOnly, - 1080
vak_permission::Mode::WorkspaceWrite, - 1081
vak_permission::Mode::FullAccess, - 1082
] { - 1083
assert!( - 1084
matches!( - 1085
engine.evaluate(shape.name, &args, mode, dir.path()), - 1086
vak_permission::Decision::Allow - 1087
), - 1088
"{} in {mode:?}", - 1089
shape.name - 1090
); - 1091
} - 1092
} - 1093
assert_eq!(presenting_tool_names().len(), SHAPES.len()); - 1094
} - 1095
- 1096
#[test] - 1097
fn every_shape_has_a_unique_tool_name() { - 1098
let names: std::collections::HashSet<&str> = SHAPES.iter().map(|s| s.name).collect(); - 1099
assert_eq!(names.len(), SHAPES.len(), "tool names must be unique"); - 1100
} - 1101
- 1102
#[test] - 1103
fn every_shape_has_at_least_one_semantic_type() { - 1104
for shape in SHAPES { - 1105
assert!( - 1106
!shape.semantic_types.is_empty(), - 1107
"{} has no semantic types", - 1108
shape.name - 1109
); - 1110
} - 1111
} - 1112
- 1113
#[test] - 1114
fn no_semantic_type_is_claimed_by_two_shapes() { - 1115
let mut seen = std::collections::HashMap::new(); - 1116
for shape in SHAPES { - 1117
for &t in shape.semantic_types { - 1118
if let Some(prev) = seen.insert(t, shape.name) { - 1119
panic!( - 1120
"semantic_type `{t}` claimed by both {prev} and {}", - 1121
shape.name - 1122
); - 1123
} - 1124
} - 1125
} - 1126
} - 1127
- 1128
#[tokio::test] - 1129
async fn a_valid_call_is_acked_and_rebuilds_into_a_card() { - 1130
let tool = EmitCardTool::all() - 1131
.into_iter() - 1132
.find(|t| t.name() == "emit_chart_card") - 1133
.unwrap(); - 1134
let args = serde_json::json!({ - 1135
"semantic_type": "chart", - 1136
"payload": { - 1137
"chart_type": "line", - 1138
"accessible_summary": "flat line", - 1139
"series": [{"name": "s1", "points": [{"x": 1, "y": 2.0}]}] - 1140
} - 1141
}); - 1142
let out = tool - 1143
.execute(&args, &ToolContext::new(std::env::temp_dir())) - 1144
.await; - 1145
assert!(!out.is_error, "expected Ok, got: {}", out.content); - 1146
assert!(out.content.contains("already on screen"), "{}", out.content); - 1147
assert!( - 1148
!out.content.contains("semantic_type"), - 1149
"ack must not echo the card" - 1150
); - 1151
let card = rebuild_call( - 1152
"emit_chart_card", - 1153
&args, - 1154
&vak_delivery::built_in_skill_registry(), - 1155
) - 1156
.expect("the call's own arguments must rebuild into a card"); - 1157
assert_eq!(card.semantic_type, "chart"); - 1158
} - 1159
- 1160
#[tokio::test] - 1161
async fn an_invalid_payload_is_a_repairable_tool_error_not_a_silent_drop() { - 1162
let tool = EmitCardTool::all() - 1163
.into_iter() - 1164
.find(|t| t.name() == "emit_chart_card") - 1165
.unwrap(); - 1166
let args = serde_json::json!({ - 1167
"semantic_type": "chart", - 1168
"payload": {"chart_type": "line", "series": []} - 1169
}); - 1170
let out = tool - 1171
.execute(&args, &ToolContext::new(std::env::temp_dir())) - 1172
.await; - 1173
assert!( - 1174
out.is_error, - 1175
"validator-rejected card must be an error the model sees" - 1176
); - 1177
assert!(out.content.contains("Fix the arguments"), "{}", out.content); - 1178
} - 1179
- 1180
#[tokio::test] - 1181
async fn a_card_far_larger_than_the_tool_output_line_limit_still_rebuilds() { - 1182
let long = "x".repeat(6000); - 1183
let args = serde_json::json!({ - 1184
"semantic_type": "research.synthesis", - 1185
"payload": { - 1186
"sources": [{"title": "S", "url": "https://example.com"}], - 1187
"takeaways": [{"text": long, "citation_indices": [1]}] - 1188
} - 1189
}); - 1190
let card = rebuild_call( - 1191
"emit_research_card", - 1192
&args, - 1193
&vak_delivery::built_in_skill_registry(), - 1194
) - 1195
.expect("size must not matter: the card comes from the call arguments"); - 1196
assert_eq!(card.semantic_type, "research.synthesis"); - 1197
} - 1198
- 1199
#[tokio::test] - 1200
async fn execute_rejects_a_semantic_type_outside_its_own_shape() { - 1201
let tool = EmitCardTool::all() - 1202
.into_iter() - 1203
.find(|t| t.name() == "emit_chart_card") - 1204
.unwrap(); - 1205
let args = serde_json::json!({ - 1206
"semantic_type": "recipe.card", - 1207
"payload": {} - 1208
}); - 1209
let ctx = ToolContext::new(std::env::temp_dir()); - 1210
let out = tool.execute(&args, &ctx).await; - 1211
assert!(out.is_error, "a chart tool must refuse a recipe type"); - 1212
} - 1213
- 1214
#[tokio::test] - 1215
async fn every_registered_semantic_type_across_all_shapes_renders() { - 1216
let skills = vak_delivery::built_in_skill_registry(); - 1217
let mut failures = Vec::new(); - 1218
for tool in EmitCardTool::all() { - 1219
for (name, semantic_type, payload) in conformance_cases() - 1220
.into_iter() - 1221
.filter(|(name, _, _)| *name == tool.name()) - 1222
{ - 1223
let args = serde_json::json!({"semantic_type": semantic_type, "payload": payload}); - 1224
let ctx = ToolContext::new(std::env::temp_dir()); - 1225
let out = tool.execute(&args, &ctx).await; - 1226
if out.is_error { - 1227
failures.push(format!( - 1228
"{semantic_type} ({name}): rejected: {}", - 1229
out.content - 1230
)); - 1231
continue; - 1232
} - 1233
let card = rebuild_call(name, &args, &skills); - 1234
if card.as_ref().map(|c| c.semantic_type.as_str()) != Some(semantic_type) { - 1235
failures.push(format!( - 1236
"{semantic_type} ({name}): did not rebuild into a card" - 1237
)); - 1238
} - 1239
} - 1240
} - 1241
assert!( - 1242
failures.is_empty(), - 1243
"{} registered types failed:\n{}", - 1244
failures.len(), - 1245
failures.join("\n") - 1246
); - 1247
} - 1248
- 1249
#[test] - 1250
fn prose_that_reads_as_a_card_gets_a_nudge_naming_the_offered_tool() { - 1251
let recipes = vak_delivery::built_in_recipes(); - 1252
let offered: Vec<String> = EmitCardTool::all() - 1253
.iter() - 1254
.map(|t| t.name().to_string()) - 1255
.collect(); - 1256
let table = "Weekly moves:\n\n| Index | Change |\n|---|---|\n| Nifty | -0.22% |\n| Sensex | -0.65% |\n"; - 1257
let nudge = presentation_check_nudge(table, &offered, &recipes) - 1258
.expect("a markdown table reads as a data grid"); - 1259
assert_eq!( - 1260
nudge.tool, "emit_table_card", - 1261
"the loop loads this for the redo" - 1262
); - 1263
assert!( - 1264
nudge.text.contains("emit_table_card") && nudge.text.contains("data.grid"), - 1265
"{}", - 1266
nudge.text - 1267
); - 1268
// no admitted tool => no nudge (never tell the model to call something it lacks) - 1269
assert!(presentation_check_nudge(table, &[], &recipes).is_none()); - 1270
} - 1271
- 1272
#[test] - 1273
fn a_request_that_reads_as_a_card_predicts_its_tool_and_small_talk_predicts_none() { - 1274
let recipes = vak_delivery::built_in_recipes(); - 1275
let request = "Compare these:\n\n| Index | Change |\n|---|---|\n| Nifty | -0.22% |\n"; - 1276
assert!(predicted_card_tools(request, &recipes).contains("emit_table_card")); - 1277
assert!(predicted_card_tools("hi there, how are you?", &recipes).is_empty()); - 1278
} - 1279
- 1280
#[test] - 1281
fn ordinary_conversation_gets_no_nudge() { - 1282
let recipes = vak_delivery::built_in_recipes(); - 1283
let offered: Vec<String> = EmitCardTool::all() - 1284
.iter() - 1285
.map(|t| t.name().to_string()) - 1286
.collect(); - 1287
for text in [ - 1288
"Sure, happy to help. What would you like to do next?", - 1289
"The capital of France is Paris.", - 1290
"I renamed the variable and the build passes.", - 1291
] { - 1292
assert!( - 1293
presentation_check_nudge(text, &offered, &recipes).is_none(), - 1294
"{text}" - 1295
); - 1296
} - 1297
} - 1298
- 1299
#[test] - 1300
fn every_emit_tool_is_reachable_from_its_types() { - 1301
for shape in SHAPES { - 1302
for t in shape.semantic_types { - 1303
assert_eq!(emit_tool_for(t), Some(shape.name)); - 1304
} - 1305
} - 1306
} - 1307
- 1308
#[test] - 1309
fn identity_digest_for_metric_is_the_whole_compact_payload() { - 1310
let payload = serde_json::json!({"label": "Uptime", "value": 99.9, "unit": "%"}); - 1311
let digest = identity_digest("metric", &payload); - 1312
// The whole card, canonical and compact — every field survives. - 1313
assert!(digest.contains("\"label\":\"Uptime\""), "{digest}"); - 1314
assert!(digest.contains("\"value\":99.9"), "{digest}"); - 1315
assert!(digest.contains("\"unit\":\"%\""), "{digest}"); - 1316
} - 1317
- 1318
#[test] - 1319
fn identity_digest_for_research_synthesis_is_takeaways_and_sources() { - 1320
let payload = serde_json::json!({ - 1321
"sources": [{"title": "Reuters", "url": "https://example.com/a"}], - 1322
"takeaways": [{"text": "Markets fell", "citation_indices": [1]}] - 1323
}); - 1324
let digest = identity_digest("research.synthesis", &payload); - 1325
assert!(digest.contains("Markets fell"), "{digest}"); - 1326
assert!(digest.contains("Reuters"), "{digest}"); - 1327
assert!(digest.contains("https://example.com/a"), "{digest}"); - 1328
// Not the raw snippet field, which the design excludes. - 1329
assert!(!digest.contains("snippet"), "{digest}"); - 1330
} - 1331
- 1332
#[test] - 1333
fn identity_digest_for_table_is_title_columns_row_count_and_first_row() { - 1334
let payload = serde_json::json!({ - 1335
"title": "Q3 Budget", - 1336
"columns": [{"key": "dept", "label": "Department"}, {"key": "spend", "label": "Spend"}], - 1337
"rows": [{"dept": "Eng", "spend": 100}, {"dept": "Sales", "spend": 50}] - 1338
}); - 1339
let digest = identity_digest("table", &payload); - 1340
assert!(digest.contains("Q3 Budget"), "{digest}"); - 1341
assert!( - 1342
digest.contains("dept") && digest.contains("spend"), - 1343
"{digest}" - 1344
); - 1345
assert!(digest.contains("rows: 2"), "{digest}"); - 1346
assert!(digest.contains("Eng"), "{digest}"); - 1347
assert!( - 1348
!digest.contains("Sales"), - 1349
"digest must not include every row: {digest}" - 1350
); - 1351
} - 1352
- 1353
#[test] - 1354
fn identity_digest_for_chart_is_title_series_points_and_summary() { - 1355
let payload = serde_json::json!({ - 1356
"title": "Revenue", - 1357
"chart_type": "line", - 1358
"accessible_summary": "rising trend", - 1359
"series": [{"name": "actual", "points": [{"x": 1, "y": 2.0}, {"x": 2, "y": 3.0}]}] - 1360
}); - 1361
let digest = identity_digest("chart", &payload); - 1362
assert!(digest.contains("Revenue"), "{digest}"); - 1363
assert!(digest.contains("actual(2)"), "{digest}"); - 1364
assert!(digest.contains("rising trend"), "{digest}"); - 1365
} - 1366
- 1367
#[test] - 1368
fn identity_digest_for_entity_is_title_type_and_fields() { - 1369
let payload = serde_json::json!({ - 1370
"title": "Paris", - 1371
"type": "city", - 1372
"population": "2.1M", - 1373
"country": "France" - 1374
}); - 1375
let digest = identity_digest("entity", &payload); - 1376
assert!(digest.contains("Paris"), "{digest}"); - 1377
assert!(digest.contains("type: city"), "{digest}"); - 1378
assert!(digest.contains("population=2.1M"), "{digest}"); - 1379
assert!(digest.contains("country=France"), "{digest}"); - 1380
} - 1381
- 1382
#[test] - 1383
fn identity_digest_for_checklist_timeline_itinerary_is_title_and_item_labels() { - 1384
let payload = serde_json::json!({ - 1385
"title": "Trip", - 1386
"items": [{"label": "Fly to Paris"}, {"label": "Check into hotel"}] - 1387
}); - 1388
for semantic_type in ["checklist", "timeline", "itinerary"] { - 1389
let digest = identity_digest(semantic_type, &payload); - 1390
assert!(digest.contains("Trip"), "{semantic_type}: {digest}"); - 1391
assert!(digest.contains("Fly to Paris"), "{semantic_type}: {digest}"); - 1392
assert!( - 1393
digest.contains("Check into hotel"), - 1394
"{semantic_type}: {digest}" - 1395
); - 1396
} - 1397
} - 1398
- 1399
#[test] - 1400
fn identity_digest_for_anything_else_is_title_plus_top_level_keys() { - 1401
let payload = - 1402
serde_json::json!({"title": "Custom", "status": "ready", "artifact_path": "a.html"}); - 1403
let digest = identity_digest("ui.preview", &payload); - 1404
assert!(digest.contains("Custom"), "{digest}"); - 1405
assert!(digest.contains("status"), "{digest}"); - 1406
assert!(digest.contains("artifact_path"), "{digest}"); - 1407
} - 1408
- 1409
#[test] - 1410
fn presentation_info_rebuilds_a_validated_call_into_a_ledger_ready_record() { - 1411
let skills = vak_delivery::built_in_skill_registry(); - 1412
let args = serde_json::json!({ - 1413
"semantic_type": "chart", - 1414
"payload": { - 1415
"title": "Revenue", - 1416
"chart_type": "line", - 1417
"accessible_summary": "flat", - 1418
"series": [{"name": "s1", "points": [{"x": 1, "y": 2.0}]}] - 1419
} - 1420
}); - 1421
let info = presentation_info("emit_chart_card", &args, &skills) - 1422
.expect("a valid call must rebuild"); - 1423
assert_eq!(info.semantic_type, "chart"); - 1424
assert_eq!(info.title, "Revenue"); - 1425
assert!( - 1426
info.identity_digest.contains("s1(1)"), - 1427
"{}", - 1428
info.identity_digest - 1429
); - 1430
assert!(info.schema_version > 0); - 1431
assert!(!info.skill_id.is_empty()); - 1432
} - 1433
- 1434
#[test] - 1435
fn presentation_info_is_none_for_an_invalid_call() { - 1436
let skills = vak_delivery::built_in_skill_registry(); - 1437
let args = serde_json::json!({"semantic_type": "chart", "payload": {"series": []}}); - 1438
assert!(presentation_info("emit_chart_card", &args, &skills).is_none()); - 1439
} - 1440
} - 1441
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.