Implement persistent native A2UI chat surfaces

This commit is contained in:
2026-07-19 16:57:43 +02:00
parent ac611e3f7f
commit fd4fd744e0
25 changed files with 2571 additions and 18 deletions

View File

@@ -0,0 +1,241 @@
use std::collections::{BTreeMap, BTreeSet};
use bds_core::db::Database;
use bds_core::engine::chat;
use bds_core::engine::chat_surfaces::{
ChartType, ChatSurfaceState, FormInputType, SurfaceKind, build_render_surface, merge_form_data,
resolve_surface_action,
};
use serde_json::{Value, json};
#[test]
fn every_render_tool_builds_the_expected_fixed_surface_type() {
let state = ChatSurfaceState::default();
for (index, (tool, expected)) in [
("render_card", SurfaceKind::Card),
("render_chart", SurfaceKind::Chart),
("render_form", SurfaceKind::Form),
("render_list", SurfaceKind::List),
("render_metric", SurfaceKind::Metric),
("render_mindmap", SurfaceKind::Mindmap),
("render_table", SurfaceKind::Table),
("render_tabs", SurfaceKind::Tabs),
]
.into_iter()
.enumerate()
{
let surface =
build_render_surface(tool, &json!({}), format!("message-surface-{index}"), &state)
.unwrap();
assert_eq!(surface.kind, expected);
}
assert!(build_render_surface("read_post", &json!({}), "x".into(), &state).is_none());
}
#[test]
fn charts_accept_all_types_aliases_and_coerce_invalid_numbers_to_zero() {
let state = ChatSurfaceState::default();
for chart_type in [
ChartType::Bar,
ChartType::StackedBar,
ChartType::Line,
ChartType::Area,
ChartType::Pie,
ChartType::Donut,
ChartType::Heatmap,
] {
let surface = build_render_surface(
"render_chart",
&json!({
"chartType": chart_type.as_str(),
"series": [{
"label": "A",
"value": "invalid",
"segments": [{"label": "one", "value": {}}, {"label": "two", "value": "2.5"}]
}]
}),
"chart".into(),
&state,
)
.unwrap();
assert_eq!(surface.chart_type, Some(chart_type));
assert_eq!(surface.series[0].value, 0.0);
assert_eq!(surface.series[0].segments[0].value, 0.0);
assert_eq!(surface.series[0].segments[1].value, 2.5);
}
let defaulted =
build_render_surface("render_chart", &json!({}), "default".into(), &state).unwrap();
assert_eq!(defaulted.chart_type, Some(ChartType::Bar));
let legacy = build_render_surface(
"render_chart",
&json!({"chart_type": "pie"}),
"legacy".into(),
&state,
)
.unwrap();
assert_eq!(legacy.chart_type, Some(ChartType::Pie));
}
#[test]
fn forms_support_every_input_type_and_restore_current_values() {
let mut state = ChatSurfaceState::default();
state.surface_data.insert(
"form".into(),
BTreeMap::from([
("title".into(), json!("Restored")),
("enabled".into(), json!(true)),
]),
);
let surface = build_render_surface(
"render_form",
&json!({
"fields": [
{"key": "title", "label": "Title", "inputType": "text", "defaultValue": "Default"},
{"key": "body", "label": "Body", "inputType": "textarea"},
{"key": "kind", "label": "Kind", "inputType": "select", "options": [{"label": "Post", "value": "post"}]},
{"key": "enabled", "label": "Enabled", "inputType": "checkbox"},
{"key": "date", "label": "Date", "inputType": "date"},
{"key": "count", "label": "Count", "inputType": "number"}
],
"submitAction": "openPost"
}),
"form".into(),
&state,
)
.unwrap();
assert_eq!(
surface
.fields
.iter()
.map(|field| field.input_type)
.collect::<Vec<_>>(),
vec![
FormInputType::Text,
FormInputType::Textarea,
FormInputType::Select,
FormInputType::Checkbox,
FormInputType::Date,
FormInputType::Number,
]
);
assert_eq!(surface.fields[0].value, json!("Restored"));
assert_eq!(surface.fields[3].value, json!(true));
let payload = merge_form_data(json!({"postId": "post-1"}), "form", &state);
assert_eq!(payload["postId"], "post-1");
assert_eq!(payload["formData"]["title"], "Restored");
assert_eq!(payload["formData"]["enabled"], true);
}
#[test]
fn tabs_restore_selection_and_nested_unknown_content_is_safe_data() {
let state = ChatSurfaceState {
surface_tabs: BTreeMap::from([("tabs".into(), 1)]),
..Default::default()
};
let surface = build_render_surface(
"render_tabs",
&json!({"tabs": [
{"label": "Known", "content": [{"type": "text", "body": "<script>bad()</script>"}]},
{"label": "Fallback", "content": [{"type": "future", "html": "<b>not markup</b>"}, 42]}
]}),
"tabs".into(),
&state,
)
.unwrap();
assert_eq!(surface.selected_index, Some(1));
assert_eq!(surface.tabs[0].content[0].kind, SurfaceKind::Text);
assert_eq!(
surface.tabs[0].content[0].body.as_deref(),
Some("<script>bad()</script>")
);
assert_eq!(surface.tabs[1].content[0].kind, SurfaceKind::Json);
assert_eq!(surface.tabs[1].content[1].kind, SurfaceKind::Text);
}
#[test]
fn unknown_render_tool_is_inspectable_json_but_non_render_tools_are_not_surfaces() {
let state = ChatSurfaceState::default();
let raw = json!({"html": "<script>alert(1)</script>", "answer": 42});
let surface = build_render_surface("render_future", &raw, "future-0".into(), &state).unwrap();
assert_eq!(surface.kind, SurfaceKind::Json);
assert_eq!(surface.raw.as_ref(), Some(&raw));
assert!(build_render_surface("run_script", &raw, "bad-0".into(), &state).is_none());
}
#[test]
fn surface_state_persists_and_reopens_against_stable_ids() {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let conversation = chat::create_conversation(db.conn(), Some("model")).unwrap();
let expected = ChatSurfaceState {
surface_data: BTreeMap::from([(
"17-surface-0".into(),
BTreeMap::from([("query".into(), json!("hello"))]),
)]),
surface_tabs: BTreeMap::from([("17-surface-1".into(), 2)]),
dismissed_surfaces: BTreeSet::from(["17-surface-2".into()]),
};
chat::put_surface_state(db.conn(), &conversation.id, &expected).unwrap();
assert_eq!(
chat::get_surface_state(db.conn(), &conversation.id).unwrap(),
expected
);
assert_eq!(
chat::get_surface_state(db.conn(), "missing").unwrap(),
ChatSurfaceState::default()
);
}
#[test]
fn assistant_action_allow_list_accepts_aliases_and_refuses_bad_payloads() {
for (action, payload, destination, entity_id) in [
(
"openPost",
json!({"postId": "post-1"}),
"posts",
Some("post-1"),
),
(
"open_media",
json!({"media_id": "media-1"}),
"media",
Some("media-1"),
),
("openSettings", json!({}), "settings", None),
(
"open_chat",
json!({"conversationId": "chat-1"}),
"chat",
Some("chat-1"),
),
(
"switchView",
json!({"view": "templates"}),
"templates",
None,
),
("set_view", json!({"view": "scripts"}), "scripts", None),
("toggleSidebar", json!({}), "toggle_sidebar", None),
("toggle_panel", json!({}), "toggle_panel", None),
(
"toggleAssistantSidebar",
json!({}),
"toggle_assistant_sidebar",
None,
),
] {
let navigation = resolve_surface_action(action, &payload).unwrap();
assert_eq!(navigation.destination, destination);
assert_eq!(navigation.entity_id.as_deref(), entity_id);
}
for (action, payload) in [
("runJavaScript", json!({"code": "alert(1)"})),
("openPost", json!({"postId": ""})),
("switchView", json!({"view": "root-shell"})),
("openMedia", Value::String("media-1".into())),
] {
assert!(resolve_surface_action(action, &payload).is_err());
}
}