From 072c90d996b94b918842cce483bcb07849b678c4 Mon Sep 17 00:00:00 2001 From: zhihao gu <1292162626@qq.com> Date: Mon, 10 Aug 2026 15:41:53 +0800 Subject: [PATCH 1/2] Add native data view charts --- package-lock.json | 26 + package.json | 1 + src-tauri/src/agent/runtime/data_view.rs | 738 ++++++++++++++++++ src-tauri/src/agent/runtime/mod.rs | 1 + .../src/agent/runtime/tests/configuration.rs | 1 + src-tauri/src/agent/runtime/tests/tools.rs | 167 ++++ src-tauri/src/agent/runtime/tool_result.rs | 95 +++ src-tauri/src/agent/runtime/tool_runtime.rs | 166 +++- .../runtime_protocol/timeline_projection.rs | 1 + src-tauri/src/agent/runtime_protocol/wire.rs | 2 + src-tauri/src/agent/runtime_protocol_tests.rs | 1 + .../domain/store/runtime_projection.rs | 16 + .../domain/store/runtime_projection_tests.rs | 97 +++ .../rollout/store/protocol_projection.rs | 8 + .../src/threads/rollout/store/turn_tests.rs | 37 + src-tauri/src/tools/registry/contributors.rs | 4 +- src-tauri/src/tools/registry/mod.rs | 86 ++ .../src/tools/registry/registry_tests.rs | 26 + src/app-core/chat/chatTurnModel.test.ts | 73 ++ src/app-core/chat/chatTurnModel.ts | 33 +- src/app-core/chat/dataView.test.ts | 95 +++ src/app-core/chat/dataView.ts | 410 ++++++++++ src/react-workbench/chat/ChatPage.test.tsx | 1 + src/react-workbench/chat/ChatPage.tsx | 51 +- src/react-workbench/chat/DataViewCard.tsx | 357 +++++++++ .../chat/ToolActivityItem.test.tsx | 15 + src/react-workbench/chat/ToolActivityItem.tsx | 16 + src/react-workbench/i18n/resources/en.ts | 10 +- src/react-workbench/i18n/resources/zh.ts | 10 +- src/react-workbench/styles/workbench.css | 283 ++++++- src/react-workbench/styles/workbench.test.ts | 13 + 31 files changed, 2817 insertions(+), 23 deletions(-) create mode 100644 src-tauri/src/agent/runtime/data_view.rs create mode 100644 src/app-core/chat/dataView.test.ts create mode 100644 src/app-core/chat/dataView.ts create mode 100644 src/react-workbench/chat/DataViewCard.tsx diff --git a/package-lock.json b/package-lock.json index ddaf3387..4fa79c15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@tauri-apps/plugin-opener": "^2", "3d-force-graph": "^1.80.0", "clsx": "^2.1.1", + "echarts": "^6.1.0", "graphology": "^0.26.0", "gsap": "^3.15.0", "highlight.js": "^11.11.1", @@ -3174,6 +3175,16 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.387", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", @@ -5898,6 +5909,12 @@ "node": ">=6.10" } }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -6332,6 +6349,15 @@ "dev": true, "license": "ISC" }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index a26887e8..9a1c5491 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@tauri-apps/plugin-opener": "^2", "3d-force-graph": "^1.80.0", "clsx": "^2.1.1", + "echarts": "^6.1.0", "graphology": "^0.26.0", "gsap": "^3.15.0", "highlight.js": "^11.11.1", diff --git a/src-tauri/src/agent/runtime/data_view.rs b/src-tauri/src/agent/runtime/data_view.rs new file mode 100644 index 00000000..8db17472 --- /dev/null +++ b/src-tauri/src/agent/runtime/data_view.rs @@ -0,0 +1,738 @@ +use chrono::{DateTime, NaiveDate}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; + +const DATA_VIEW_SCHEMA_VERSION: &str = "tinybot.data_view.v1"; +const DATA_VIEW_MIME_TYPE: &str = "application/vnd.tinybot.data-view+json;version=1"; +const MAX_ARTIFACT_BYTES: usize = 256 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct DataViewDocument { + schema_version: String, + title: String, + insight: String, + dataset: DataViewDataset, + view: DataView, + provenance: DataViewProvenance, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DataViewDataset { + columns: Vec, + rows: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DataViewColumn { + key: String, + label: String, + #[serde(rename = "type")] + kind: DataViewColumnType, + #[serde(default, skip_serializing_if = "Option::is_none")] + format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + currency: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + unit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fraction_digits: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DataViewColumnType { + Category, + String, + Number, + Date, + Datetime, + Boolean, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DataViewNumberFormat { + Number, + Integer, + Compact, + Percent, + Currency, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DataViewRow { + id: String, + values: Map, + #[serde(default)] + source_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum DataView { + Metrics { + items: Vec, + }, + Table { + #[serde(default)] + fields: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + default_sort: Option, + }, + Cartesian { + x: String, + series: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + stack: Option, + }, + Waterfall { + category: String, + value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + total_field: Option, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DataViewMetric { + field: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + comparison_field: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + direction: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum DataViewMetricDirection { + HigherIsBetter, + LowerIsBetter, + Neutral, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DataViewSort { + field: String, + direction: DataViewSortDirection, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum DataViewSortDirection { + Asc, + Desc, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DataViewSeries { + field: String, + mark: DataViewMark, + #[serde(default, skip_serializing_if = "Option::is_none")] + axis: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DataViewMark { + Line, + Bar, + Area, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DataViewAxis { + Left, + Right, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DataViewStack { + None, + Normal, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DataViewProvenance { + status: DataViewProvenanceStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + as_of: Option, + #[serde(default)] + sources: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + methodology: Option, + #[serde(default)] + caveats: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DataViewProvenanceStatus { + Sourced, + UserProvided, + Unsourced, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DataViewSource { + id: String, + kind: DataViewSourceKind, + title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + uri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + locator: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + published_at: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DataViewSourceKind { + Url, + File, + UserInput, +} + +pub(super) struct PublishedDataView { + pub artifact: Value, + pub artifact_id: String, + pub byte_size: usize, + pub column_count: usize, + pub row_count: usize, + pub title: String, + pub warnings: Vec, +} + +pub(super) fn publish_data_view( + arguments: &Map, + turn_id: &str, + tool_call_id: &str, +) -> Result { + let schema_version = arguments + .get("schemaVersion") + .and_then(Value::as_str) + .unwrap_or_default(); + if schema_version != DATA_VIEW_SCHEMA_VERSION { + return Err(format!( + "data_view_schema_unsupported: schemaVersion must be `{DATA_VIEW_SCHEMA_VERSION}`" + )); + } + let document: DataViewDocument = serde_json::from_value(Value::Object(arguments.clone())) + .map_err(|error| format!("data_view_invalid_shape: {error}"))?; + validate_document(&document)?; + + let content = serde_json::to_value(&document) + .map_err(|error| format!("data_view_invalid_shape: failed to serialize view: {error}"))?; + let byte_size = serde_json::to_vec(&content) + .map_err(|error| format!("data_view_invalid_shape: failed to size view: {error}"))? + .len(); + if byte_size > MAX_ARTIFACT_BYTES { + return Err(format!( + "data_view_limit_exceeded: serialized content is {byte_size} bytes; maximum is {MAX_ARTIFACT_BYTES}" + )); + } + + let artifact_id = artifact_id(turn_id, tool_call_id); + let warnings = validation_warnings(&document); + let artifact = serde_json::json!({ + "id": artifact_id, + "kind": "data_view", + "mimeType": DATA_VIEW_MIME_TYPE, + "title": document.title, + "preview": document.insight, + "sizeBytes": byte_size, + "status": "available", + "content": content, + "warnings": warnings, + }); + Ok(PublishedDataView { + artifact, + artifact_id, + byte_size, + column_count: document.dataset.columns.len(), + row_count: document.dataset.rows.len(), + title: document.title, + warnings, + }) +} + +fn artifact_id(turn_id: &str, tool_call_id: &str) -> String { + let digest = Sha256::digest(format!("{turn_id}:{tool_call_id}")); + let suffix = digest[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("dv_{suffix}") +} + +fn validate_document(document: &DataViewDocument) -> Result<(), String> { + validate_text("title", &document.title, 1, 160)?; + validate_text("insight", &document.insight, 1, 500)?; + validate_limit("columns", document.dataset.columns.len(), 1, 20)?; + validate_limit("rows", document.dataset.rows.len(), 1, 1_000)?; + validate_limit("sources", document.provenance.sources.len(), 0, 64)?; + + let mut columns = HashMap::new(); + for column in &document.dataset.columns { + validate_identifier("column key", &column.key)?; + validate_text("column label", &column.label, 1, 120)?; + if columns.insert(column.key.as_str(), column).is_some() { + return Err(format!( + "data_view_invalid_shape: duplicate column key `{}`", + column.key + )); + } + if column.fraction_digits.is_some_and(|digits| digits > 4) { + return Err(format!( + "data_view_invalid_shape: column `{}` fractionDigits must be between 0 and 4", + column.key + )); + } + if column.kind != DataViewColumnType::Number + && (column.format.is_some() + || column.currency.is_some() + || column.unit.is_some() + || column.fraction_digits.is_some()) + { + return Err(format!( + "data_view_invalid_shape: non-number column `{}` cannot declare numeric formatting", + column.key + )); + } + if column.format == Some(DataViewNumberFormat::Currency) { + let valid_currency = column.currency.as_deref().is_some_and(|currency| { + currency.len() == 3 + && currency + .bytes() + .all(|character| character.is_ascii_uppercase()) + }); + if !valid_currency { + return Err(format!( + "data_view_invalid_shape: currency column `{}` requires a three-letter uppercase currency code", + column.key + )); + } + } else if column.currency.is_some() { + return Err(format!( + "data_view_invalid_shape: column `{}` declares currency without currency format", + column.key + )); + } + } + + let mut source_ids = HashSet::new(); + for source in &document.provenance.sources { + validate_identifier("source id", &source.id)?; + validate_text("source title", &source.title, 1, 240)?; + if !source_ids.insert(source.id.as_str()) { + return Err(format!( + "data_view_invalid_shape: duplicate source id `{}`", + source.id + )); + } + if source.kind == DataViewSourceKind::Url { + let uri = source.uri.as_deref().ok_or_else(|| { + format!( + "data_view_invalid_shape: URL source `{}` requires uri", + source.id + ) + })?; + let parsed = url::Url::parse(uri).map_err(|error| { + format!( + "data_view_invalid_shape: source `{}` has invalid URL: {error}", + source.id + ) + })?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(format!( + "data_view_invalid_shape: source `{}` URL must use http or https", + source.id + )); + } + } + } + if document.provenance.status == DataViewProvenanceStatus::Sourced + && document.provenance.sources.is_empty() + { + return Err( + "data_view_invalid_shape: sourced provenance requires at least one source".to_string(), + ); + } + + let mut row_ids = HashSet::new(); + for row in &document.dataset.rows { + validate_identifier("row id", &row.id)?; + if !row_ids.insert(row.id.as_str()) { + return Err(format!( + "data_view_invalid_shape: duplicate row id `{}`", + row.id + )); + } + for (key, value) in &row.values { + let column = columns.get(key.as_str()).ok_or_else(|| { + format!("data_view_unknown_field: row `{}` contains `{key}`", row.id) + })?; + validate_cell_value(&row.id, column, value)?; + } + for source_id in &row.source_ids { + if !source_ids.contains(source_id.as_str()) { + return Err(format!( + "data_view_unknown_source: row `{}` references `{source_id}`", + row.id + )); + } + } + } + validate_view(&document.view, &columns) +} + +fn validate_view(view: &DataView, columns: &HashMap<&str, &DataViewColumn>) -> Result<(), String> { + match view { + DataView::Metrics { items } => { + validate_limit("metric items", items.len(), 1, 6)?; + for item in items { + require_column_type(columns, &item.field, &[DataViewColumnType::Number])?; + if let Some(field) = item.comparison_field.as_deref() { + require_column_type(columns, field, &[DataViewColumnType::Number])?; + } + } + } + DataView::Table { + fields, + default_sort, + } => { + let mut seen = HashSet::new(); + for field in fields { + require_column(columns, field)?; + if !seen.insert(field) { + return Err(format!( + "data_view_invalid_encoding: table field `{field}` is repeated" + )); + } + } + if let Some(sort) = default_sort { + require_column(columns, &sort.field)?; + } + } + DataView::Cartesian { x, series, stack } => { + require_column_type( + columns, + x, + &[ + DataViewColumnType::Category, + DataViewColumnType::String, + DataViewColumnType::Date, + DataViewColumnType::Datetime, + ], + )?; + validate_limit("cartesian series", series.len(), 1, 6)?; + for item in series { + require_column_type(columns, &item.field, &[DataViewColumnType::Number])?; + } + let right_series = series + .iter() + .filter(|item| item.axis == Some(DataViewAxis::Right)) + .collect::>(); + if !right_series.is_empty() { + if series.len() > 2 { + return Err( + "data_view_invalid_encoding: a right axis is allowed only with at most two series" + .to_string(), + ); + } + let left = series + .iter() + .find(|item| item.axis != Some(DataViewAxis::Right)) + .ok_or_else(|| { + "data_view_invalid_encoding: a right axis requires a left-axis series" + .to_string() + })?; + let left_column = columns[left.field.as_str()]; + let right_column = columns[right_series[0].field.as_str()]; + if left_column.format == right_column.format + && left_column.unit == right_column.unit + && left_column.currency == right_column.currency + { + return Err( + "data_view_invalid_encoding: right axis must use a different unit or format" + .to_string(), + ); + } + } + if *stack == Some(DataViewStack::Normal) { + if series.iter().any(|item| item.mark == DataViewMark::Line) + || series + .iter() + .map(|item| item.axis.unwrap_or(DataViewAxis::Left)) + .collect::>() + .len() + != 1 + { + return Err( + "data_view_invalid_encoding: stacked series must be bar or area marks on one axis" + .to_string(), + ); + } + } + } + DataView::Waterfall { + category, + value, + total_field, + } => { + require_column_type( + columns, + category, + &[DataViewColumnType::Category, DataViewColumnType::String], + )?; + require_column_type(columns, value, &[DataViewColumnType::Number])?; + if let Some(field) = total_field.as_deref() { + require_column_type(columns, field, &[DataViewColumnType::Boolean])?; + } + } + } + Ok(()) +} + +fn require_column<'a>( + columns: &'a HashMap<&str, &DataViewColumn>, + field: &str, +) -> Result<&'a DataViewColumn, String> { + columns + .get(field) + .copied() + .ok_or_else(|| format!("data_view_unknown_field: view references `{field}`")) +} + +fn require_column_type( + columns: &HashMap<&str, &DataViewColumn>, + field: &str, + allowed: &[DataViewColumnType], +) -> Result<(), String> { + let column = require_column(columns, field)?; + if !allowed.contains(&column.kind) { + return Err(format!( + "data_view_invalid_encoding: `{field}` has an incompatible column type" + )); + } + Ok(()) +} + +fn validate_cell_value(row_id: &str, column: &DataViewColumn, value: &Value) -> Result<(), String> { + if value.is_null() { + return Ok(()); + } + let valid = match column.kind { + DataViewColumnType::Category | DataViewColumnType::String => value.is_string(), + DataViewColumnType::Number => value.is_number(), + DataViewColumnType::Boolean => value.is_boolean(), + DataViewColumnType::Date => value.as_str().is_some_and(|value| { + NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok() && value.len() == 10 + }), + DataViewColumnType::Datetime => value + .as_str() + .is_some_and(|value| DateTime::parse_from_rfc3339(value).is_ok()), + }; + if !valid { + return Err(format!( + "data_view_type_mismatch: row `{row_id}` field `{}` does not match its declared type", + column.key + )); + } + Ok(()) +} + +fn validate_identifier(label: &str, value: &str) -> Result<(), String> { + let mut characters = value.chars(); + let valid_first = characters + .next() + .is_some_and(|character| character.is_ascii_alphabetic() || character == '_'); + let valid_rest = characters + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-')); + if !valid_first || !valid_rest { + return Err(format!( + "data_view_invalid_shape: {label} `{value}` is not a valid identifier" + )); + } + Ok(()) +} + +fn validate_text(label: &str, value: &str, min: usize, max: usize) -> Result<(), String> { + let length = value.trim().chars().count(); + if length < min || length > max { + return Err(format!( + "data_view_invalid_shape: {label} must contain between {min} and {max} characters" + )); + } + Ok(()) +} + +fn validate_limit(label: &str, count: usize, min: usize, max: usize) -> Result<(), String> { + if count < min || count > max { + return Err(format!( + "data_view_limit_exceeded: {label} count must be between {min} and {max}; received {count}" + )); + } + Ok(()) +} + +fn validation_warnings(document: &DataViewDocument) -> Vec { + let mut warnings = Vec::new(); + if document.provenance.status == DataViewProvenanceStatus::Unsourced { + warnings.push("unsourced_data".to_string()); + } + if document.provenance.sources.len() > 1 + && document + .dataset + .rows + .iter() + .all(|row| row.source_ids.is_empty()) + { + warnings.push("no_row_level_source_mapping".to_string()); + } + if matches!(&document.view, DataView::Cartesian { series, .. } if series.len() > 4) { + warnings.push("many_series".to_string()); + } + if normalized_comparison_text(&document.title) == normalized_comparison_text(&document.insight) + { + warnings.push("title_repeats_insight".to_string()); + } + warnings +} + +fn normalized_comparison_text(value: &str) -> String { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn valid_view() -> Map { + json!({ + "schemaVersion": "tinybot.data_view.v1", + "title": "Revenue and growth", + "insight": "Revenue rose while growth slowed.", + "dataset": { + "columns": [ + { "key": "period", "label": "Period", "type": "category" }, + { "key": "revenue", "label": "Revenue", "type": "number", "format": "currency", "currency": "USD", "unit": "million", "fractionDigits": 0 }, + { "key": "growth", "label": "Growth", "type": "number", "format": "percent", "fractionDigits": 1 } + ], + "rows": [ + { "id": "fy24", "values": { "period": "FY2024", "revenue": 391035, "growth": 9.4 }, "sourceIds": ["filing"] }, + { "id": "fy25", "values": { "period": "FY2025", "revenue": 403155, "growth": 3.1 }, "sourceIds": ["filing"] } + ] + }, + "view": { + "kind": "cartesian", + "x": "period", + "series": [ + { "field": "revenue", "mark": "bar", "axis": "left" }, + { "field": "growth", "mark": "line", "axis": "right" } + ], + "stack": "none" + }, + "provenance": { + "status": "sourced", + "asOf": "2025-09-27", + "sources": [{ "id": "filing", "kind": "url", "title": "FY2025 Form 10-K", "uri": "https://example.com/filing" }], + "methodology": "Reported annual revenue.", + "caveats": [] + } + }) + .as_object() + .unwrap() + .clone() + } + + #[test] + fn publishes_a_valid_mixed_chart() { + let published = publish_data_view(&valid_view(), "turn-1", "call-1").unwrap(); + assert!(published.artifact_id.starts_with("dv_")); + assert_eq!(published.column_count, 3); + assert_eq!(published.row_count, 2); + assert_eq!(published.artifact["kind"], "data_view"); + assert_eq!(published.artifact["content"]["view"]["kind"], "cartesian"); + assert!(published.warnings.is_empty()); + } + + #[test] + fn omits_absent_optional_fields_from_published_content() { + let mut input = Value::Object(valid_view()); + input["view"] = json!({ + "kind": "metrics", + "items": [{ "field": "growth" }] + }); + input["provenance"].as_object_mut().unwrap().remove("asOf"); + input["provenance"] + .as_object_mut() + .unwrap() + .remove("methodology"); + + let published = publish_data_view( + input.as_object().unwrap(), + "turn-optional-fields", + "call-optional-fields", + ) + .unwrap(); + let first_column = published.artifact["content"]["dataset"]["columns"][0] + .as_object() + .unwrap(); + assert!(!first_column.contains_key("format")); + assert!(!first_column.contains_key("currency")); + assert!(!first_column.contains_key("unit")); + assert!(!first_column.contains_key("fractionDigits")); + let metric = published.artifact["content"]["view"]["items"][0] + .as_object() + .unwrap(); + assert!(!metric.contains_key("comparisonField")); + assert!(!metric.contains_key("direction")); + let provenance = published.artifact["content"]["provenance"] + .as_object() + .unwrap(); + assert!(!provenance.contains_key("asOf")); + assert!(!provenance.contains_key("methodology")); + } + + #[test] + fn rejects_unknown_row_fields_without_creating_an_artifact() { + let mut input = Value::Object(valid_view()); + input["dataset"]["rows"][0]["values"]["invented"] = json!(42); + let error = publish_data_view(input.as_object().unwrap(), "turn-1", "call-unknown-field") + .err() + .expect("invalid view must fail"); + assert!(error.starts_with("data_view_unknown_field:")); + } + + #[test] + fn warns_when_provenance_is_unsourced() { + let mut input = Value::Object(valid_view()); + input["provenance"]["status"] = json!("unsourced"); + input["provenance"]["sources"] = json!([]); + for row in input["dataset"]["rows"].as_array_mut().unwrap() { + row.as_object_mut().unwrap().remove("sourceIds"); + } + let published = + publish_data_view(input.as_object().unwrap(), "turn-1", "call-unsourced").unwrap(); + assert_eq!(published.warnings, vec!["unsourced_data"]); + } +} diff --git a/src-tauri/src/agent/runtime/mod.rs b/src-tauri/src/agent/runtime/mod.rs index 2eadb62c..0b6bda35 100644 --- a/src-tauri/src/agent/runtime/mod.rs +++ b/src-tauri/src/agent/runtime/mod.rs @@ -20,6 +20,7 @@ mod context; mod context_contributors; mod context_manager; mod continuations; +mod data_view; mod events; mod hooks; mod instructions; diff --git a/src-tauri/src/agent/runtime/tests/configuration.rs b/src-tauri/src/agent/runtime/tests/configuration.rs index 726009db..5d5e9bc6 100644 --- a/src-tauri/src/agent/runtime/tests/configuration.rs +++ b/src-tauri/src/agent/runtime/tests/configuration.rs @@ -584,6 +584,7 @@ fn chat_completion_request_exposes_only_foundational_model_tools() { assert!(request.get("parallel_tool_calls").is_none()); assert!(names.contains(&"update_plan")); assert!(names.contains(&"tool_search")); + assert!(names.contains(&"publish_data_view")); assert!(names.contains(&"request_user_input")); assert!(!names.contains(&"workspace_read_file")); assert!(!names.contains(&"subagent_spawn")); diff --git a/src-tauri/src/agent/runtime/tests/tools.rs b/src-tauri/src/agent/runtime/tests/tools.rs index e5fab98a..27654ec2 100644 --- a/src-tauri/src/agent/runtime/tests/tools.rs +++ b/src-tauri/src/agent/runtime/tests/tools.rs @@ -1996,6 +1996,173 @@ fn emits_tool_result_envelope_with_legacy_content_projection() { ); } +#[test] +fn publish_data_view_emits_a_persistable_artifact_and_continues_the_turn() { + let arguments = json!({ + "schemaVersion": "tinybot.data_view.v1", + "title": "Quarterly revenue", + "insight": "Revenue increased in Q2.", + "dataset": { + "columns": [ + { "key": "quarter", "label": "Quarter", "type": "category" }, + { "key": "revenue", "label": "Revenue", "type": "number", "unit": "million" } + ], + "rows": [ + { "id": "q1", "values": { "quarter": "Q1", "revenue": 100 } }, + { "id": "q2", "values": { "quarter": "Q2", "revenue": 120 } } + ] + }, + "view": { + "kind": "cartesian", + "x": "quarter", + "series": [{ "field": "revenue", "mark": "bar" }] + }, + "provenance": { + "status": "user_provided", + "sources": [], + "caveats": [] + } + }) + .to_string(); + let services = NativeAgentRuntimeServices::default(); + let result = run_native_agent_turn_with_config( + &services, + json!({ + "runtime": "rust", + "turnId": "turn-data-view", + "sessionId": "websocket:chat-data-view", + "maxIterations": 2, + "messages": [{ "role": "user", "content": "chart revenue" }] + }), + json!({ + "agents": { "defaults": { "provider": "fixture", "model": "fixture-model" } }, + "providers": { + "fixture": { + "responses": [ + { + "content": "", + "toolCalls": [{ + "id": "call-data-view", + "name": "publish_data_view", + "argumentsJson": arguments + }] + }, + { "content": "Revenue increased in Q2." } + ] + } + } + }), + ) + .expect("data view tool run should succeed"); + + assert_eq!(result["stopReason"], "final_response"); + let tool_result = result["runtimeEvents"] + .as_array() + .expect("events should be an array") + .iter() + .find(|event| event["eventName"] == "agent.tool.result") + .expect("data view tool result should be emitted"); + let envelope = &tool_result["payload"]["envelope"]; + assert_eq!(envelope["status"], "ok"); + assert_eq!(envelope["structured"]["kind"], "data_view_published"); + assert_eq!(envelope["artifacts"][0]["kind"], "data_view"); + assert_eq!( + envelope["artifacts"][0]["content"]["schemaVersion"], + "tinybot.data_view.v1" + ); + assert_eq!( + result["completedToolResults"][0]["envelope"]["artifacts"][0]["id"], + envelope["artifacts"][0]["id"] + ); +} + +#[test] +fn publish_data_view_handles_multiple_calls_from_one_provider_response() { + let tool_calls = [ + ("call-data-view-1", "Revenue", "bar"), + ("call-data-view-2", "Profit", "line"), + ("call-data-view-3", "Cash flow", "bar"), + ] + .into_iter() + .map(|(id, title, mark)| { + json!({ + "id": id, + "name": "publish_data_view", + "argumentsJson": json!({ + "schemaVersion": "tinybot.data_view.v1", + "title": title, + "insight": format!("{title} increased in Q2."), + "dataset": { + "columns": [ + { "key": "quarter", "label": "Quarter", "type": "category" }, + { "key": "value", "label": title, "type": "number" } + ], + "rows": [ + { "id": "q1", "values": { "quarter": "Q1", "value": 100 } }, + { "id": "q2", "values": { "quarter": "Q2", "value": 120 } } + ] + }, + "view": { + "kind": "cartesian", + "x": "quarter", + "series": [{ "field": "value", "mark": mark }] + }, + "provenance": { + "status": "user_provided", + "sources": [], + "caveats": [] + } + }) + .to_string() + }) + }) + .collect::>(); + let services = NativeAgentRuntimeServices::default(); + let result = run_native_agent_turn_with_config( + &services, + json!({ + "runtime": "rust", + "turnId": "turn-multiple-data-views", + "sessionId": "websocket:chat-multiple-data-views", + "maxIterations": 2, + "messages": [{ "role": "user", "content": "show several charts" }] + }), + json!({ + "agents": { "defaults": { "provider": "fixture", "model": "fixture-model" } }, + "providers": { + "fixture": { + "responses": [ + { "content": "", "toolCalls": tool_calls }, + { "content": "Published three data views." } + ] + } + } + }), + ) + .expect("multiple data view tool calls should succeed"); + + assert_eq!(result["stopReason"], "final_response"); + let completed = result["completedToolResults"] + .as_array() + .expect("completed tool results should be present"); + assert_eq!(completed.len(), 3); + assert!(completed.iter().all(|result| { + result["envelope"]["status"] == "ok" + && result["envelope"]["artifacts"][0]["kind"] == "data_view" + })); + let result_call_ids = result["runtimeEvents"] + .as_array() + .expect("events should be an array") + .iter() + .filter(|event| event["eventName"] == "agent.tool.result") + .map(|event| event["payload"]["toolCallId"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!( + result_call_ids, + vec!["call-data-view-1", "call-data-view-2", "call-data-view-3"] + ); +} + #[test] fn subagent_tools_share_manager_state_without_copying_child_transcript_to_parent() { let services = NativeAgentRuntimeServices::default().with_test_tool_registry_entries( diff --git a/src-tauri/src/agent/runtime/tool_result.rs b/src-tauri/src/agent/runtime/tool_result.rs index 6c3d441e..f14e8ab1 100644 --- a/src-tauri/src/agent/runtime/tool_result.rs +++ b/src-tauri/src/agent/runtime/tool_result.rs @@ -80,6 +80,44 @@ impl NativeToolResultEnvelope { ) } + fn data_view_success( + tool_call: &NativeAgentToolCall, + artifact_id: &str, + title: &str, + warnings: &[String], + artifact: Value, + ) -> Self { + let summary = format!("Published data view: {title}"); + let model_content = + format!("Published data view {artifact_id} as an immutable inline chat artifact."); + Self::from_parts( + "ok", + summary, + model_content, + serde_json::json!({ + "type": "data_view", + "artifactId": artifact_id, + "title": title, + "actions": [], + }), + serde_json::json!({ + "kind": "data_view_published", + "schemaVersion": "tinybot.data_view.v1", + "artifactId": artifact_id, + "warnings": warnings, + }), + serde_json::json!([]), + serde_json::json!([artifact]), + serde_json::json!([{ "type": "session_artifact_appended", "artifactId": artifact_id }]), + tool_call, + serde_json::json!({ + "artifactId": artifact_id, + "schemaVersion": "tinybot.data_view.v1", + "warnings": warnings, + }), + ) + } + fn from_parts( status: &str, summary: String, @@ -197,6 +235,31 @@ impl NativeAgentToolResult { envelope, } } + + pub(crate) fn data_view_success( + tool_call: &NativeAgentToolCall, + artifact_id: &str, + title: &str, + warnings: &[String], + artifact: Value, + ) -> Self { + let envelope = NativeToolResultEnvelope::data_view_success( + tool_call, + artifact_id, + title, + warnings, + artifact, + ); + let model_content = envelope + .get("modelContent") + .and_then(Value::as_str) + .expect("data view result must include model content") + .to_string(); + Self { + content: Value::String(model_content), + envelope, + } + } } fn generic_ui(ui_type: &str, title: String) -> Value { @@ -283,4 +346,36 @@ mod tests { assert_eq!(model_content["result"], raw); assert_eq!(result.envelope["raw"], raw); } + + #[test] + fn data_view_envelope_keeps_content_in_a_first_class_artifact() { + let tool_call = NativeAgentToolCall { + id: "call-data-view".to_string(), + name: "publish_data_view".to_string(), + arguments_json: "{}".to_string(), + result: Value::Null, + }; + let artifact = json!({ + "id": "dv_1234", + "kind": "data_view", + "content": { "schemaVersion": "tinybot.data_view.v1" } + }); + + let result = NativeAgentToolResult::data_view_success( + &tool_call, + "dv_1234", + "Revenue", + &["unsourced_data".to_string()], + artifact, + ); + + assert_eq!(result.envelope["structured"]["kind"], "data_view_published"); + assert_eq!(result.envelope["artifacts"][0]["id"], "dv_1234"); + assert_eq!(result.envelope["artifacts"][0]["kind"], "data_view"); + assert_eq!(result.envelope["raw"]["artifactId"], "dv_1234"); + assert!(!result.envelope["modelContent"] + .as_str() + .unwrap() + .contains("schemaVersion")); + } } diff --git a/src-tauri/src/agent/runtime/tool_runtime.rs b/src-tauri/src/agent/runtime/tool_runtime.rs index cec9382e..04a795ce 100644 --- a/src-tauri/src/agent/runtime/tool_runtime.rs +++ b/src-tauri/src/agent/runtime/tool_runtime.rs @@ -17,7 +17,9 @@ use crate::agent::runtime_protocol::{ AgentEventKind, AgentRuntimePhase, PendingAgentEvent, TerminalEvent, ToolLifecycleEvent, }; use crate::tools::registry::ToolCancellationMode; -use crate::tools::registry::{REQUEST_USER_INPUT_METHOD, TOOL_SEARCH_METHOD, UPDATE_PLAN_METHOD}; +use crate::tools::registry::{ + PUBLISH_DATA_VIEW_METHOD, REQUEST_USER_INPUT_METHOD, TOOL_SEARCH_METHOD, UPDATE_PLAN_METHOD, +}; use futures_util::{future::join_all, FutureExt}; use serde::Deserialize; use serde_json::Value; @@ -298,9 +300,171 @@ pub(super) async fn execute_tool_calls_for_iteration( }; } + if tool_calls + .iter() + .any(|tool_call| tool_call.name == PUBLISH_DATA_VIEW_METHOD) + { + if tool_calls + .iter() + .any(|tool_call| tool_call.name != PUBLISH_DATA_VIEW_METHOD) + { + let tool_call = tool_calls + .iter() + .find(|tool_call| tool_call.name == PUBLISH_DATA_VIEW_METHOD) + .expect("publish_data_view presence was checked"); + return tool_error_result( + services, + context, + state, + iteration, + tool_call, + "publish_data_view cannot be mixed with other tools in its provider response" + .to_string(), + ); + } + return execute_publish_data_views(services, context, state, iteration, tool_calls); + } + execute_tool_batch(services, context, state, iteration, tool_calls).await } +fn execute_publish_data_views( + services: &NativeAgentRuntimeServices, + context: &AgentTurnContext, + state: &mut AgentTurnState, + iteration: i64, + tool_calls: Vec, +) -> Result { + let is_multi_call = tool_calls.len() > 1; + let planned_calls = tool_calls + .into_iter() + .enumerate() + .map(|(index, tool_call)| PlannedToolCall { + index, + mode: ToolExecutionMode::Exclusive, + tool_call, + }) + .collect::>(); + if is_multi_call { + queue_tool_batch(services, context, state, iteration, &planned_calls)?; + } + + for (wave_index, planned_call) in planned_calls.into_iter().enumerate() { + if context_is_cancelled(context) { + state.clear_pending_tool_calls(); + return cancelled_result(services, context, state, iteration); + } + let tool_call = if is_multi_call { + let wave = ToolWave::Exclusive(planned_call); + mark_tool_wave_running(services, context, state, iteration, wave_index, &wave)?; + match wave { + ToolWave::Exclusive(call) => call.tool_call, + ToolWave::Parallel(_) => { + unreachable!("data views are always published sequentially") + } + } + } else { + start_tool_call(services, context, state, iteration, &planned_call.tool_call)?; + planned_call.tool_call + }; + + let result = publish_data_view_result(context, state, &tool_call); + commit_tool_observation(context, state, iteration, tool_call.into_original(), result)?; + } + + state.clear_pending_tool_calls(); + state.transition_phase( + AgentRuntimePhase::Planning, + iteration, + AgentEventKind::ToolResult.wire_name(), + )?; + save_phase_checkpoint( + services, + context, + state.phase.as_str(), + state.active_checkpoint_payload("data_views_published"), + ); + Ok(NativeAgentToolExecutionOutcome::Continue) +} + +fn publish_data_view_result( + context: &AgentTurnContext, + state: &AgentTurnState, + tool_call: &PreparedToolCall, +) -> super::NativeAgentToolResult { + context.metrics().increment("tool.started"); + let tool_started_at = std::time::Instant::now(); + let published_count = state + .completed_tool_results + .iter() + .filter(|result| { + result + .pointer("/envelope/structured/kind") + .and_then(Value::as_str) + == Some("data_view_published") + }) + .count(); + if published_count >= 3 { + context + .metrics() + .record_duration("tool.durationMs", tool_started_at.elapsed()); + context.metrics().increment("tool.failed"); + return super::NativeAgentToolResult::generic_error( + tool_call, + "data_view_turn_limit: at most three data views may be published in one turn" + .to_string(), + ); + } + + let published = match super::data_view::publish_data_view( + tool_call.arguments(), + &context.turn_id, + &tool_call.id, + ) { + Ok(published) => published, + Err(error) => { + context + .metrics() + .record_duration("tool.durationMs", tool_started_at.elapsed()); + context.metrics().increment("tool.failed"); + eprintln!( + "data view rejected: {}", + serde_json::json!({ + "turnId": context.turn_id, + "toolCallId": tool_call.id, + "errorCode": error.split(':').next().unwrap_or("data_view_invalid_shape"), + }) + ); + return super::NativeAgentToolResult::generic_error(tool_call, error); + } + }; + let result = super::NativeAgentToolResult::data_view_success( + &tool_call, + &published.artifact_id, + &published.title, + &published.warnings, + published.artifact, + ); + context + .metrics() + .record_duration("tool.durationMs", tool_started_at.elapsed()); + context.metrics().increment("tool.completed"); + eprintln!( + "data view published: {}", + serde_json::json!({ + "turnId": context.turn_id, + "toolCallId": tool_call.id, + "artifactId": published.artifact_id, + "schemaVersion": "tinybot.data_view.v1", + "columns": published.column_count, + "rows": published.row_count, + "byteSize": published.byte_size, + "warningCount": published.warnings.len(), + }) + ); + result +} + fn execute_tool_search( services: &NativeAgentRuntimeServices, context: &mut AgentTurnContext, diff --git a/src-tauri/src/agent/runtime_protocol/timeline_projection.rs b/src-tauri/src/agent/runtime_protocol/timeline_projection.rs index 977ef88c..aa75dbc4 100644 --- a/src-tauri/src/agent/runtime_protocol/timeline_projection.rs +++ b/src-tauri/src/agent/runtime_protocol/timeline_projection.rs @@ -726,6 +726,7 @@ fn legacy_item_data( ), name: item_string(payload, &["toolName", "tool_name", "name"]).unwrap_or_default(), status: item_string(payload, &["status"]).unwrap_or_else(|| "running".to_string()), + result_status: item_string(payload, &["resultStatus", "result_status"]), args: payload .get("arguments") .or_else(|| payload.get("args")) diff --git a/src-tauri/src/agent/runtime_protocol/wire.rs b/src-tauri/src/agent/runtime_protocol/wire.rs index bd36cd1b..1cf0fae2 100644 --- a/src-tauri/src/agent/runtime_protocol/wire.rs +++ b/src-tauri/src/agent/runtime_protocol/wire.rs @@ -200,6 +200,8 @@ pub enum AgentTurnItemData { tool_call_id: String, name: String, status: String, + #[serde(skip_serializing_if = "Option::is_none")] + result_status: Option, args: Value, result: Value, detail_id: Option, diff --git a/src-tauri/src/agent/runtime_protocol_tests.rs b/src-tauri/src/agent/runtime_protocol_tests.rs index 07d153f6..bf390694 100644 --- a/src-tauri/src/agent/runtime_protocol_tests.rs +++ b/src-tauri/src/agent/runtime_protocol_tests.rs @@ -57,6 +57,7 @@ fn turn_item_serializes_stable_shape() { tool_call_id: "tool-1".to_string(), name: "read_file".to_string(), status: "running".to_string(), + result_status: None, args: Value::Null, result: Value::Null, detail_id: None, diff --git a/src-tauri/src/threads/domain/store/runtime_projection.rs b/src-tauri/src/threads/domain/store/runtime_projection.rs index e16fe89d..c9d2b523 100644 --- a/src-tauri/src/threads/domain/store/runtime_projection.rs +++ b/src-tauri/src/threads/domain/store/runtime_projection.rs @@ -43,6 +43,9 @@ fn semantic_event_from_thread_item(item: &ThreadItem) -> Option<(AgentEventKind, "toolCallId": semantic_item_id(item), "result": result, }); + if let Some(result_status) = persisted_tool_result_status(value, &result) { + payload["resultStatus"] = Value::String(result_status); + } if let Some(summary) = summary { payload["summary"] = Value::String(summary); } @@ -80,6 +83,19 @@ fn semantic_event_from_thread_item(item: &ThreadItem) -> Option<(AgentEventKind, } } +fn persisted_tool_result_status(value: &Value, result: &Value) -> Option { + let status = result + .get("status") + .or_else(|| value.get("status")) + .and_then(Value::as_str)?; + match status { + "ok" | "completed" | "success" => Some("ok".to_string()), + "error" | "failed" => Some("error".to_string()), + "denied" => Some("denied".to_string()), + _ => None, + } +} + fn persisted_context_state_event(value: &Value) -> Option<(AgentEventKind, Value)> { let event = if value.get("eventName").is_some() { value diff --git a/src-tauri/src/threads/domain/store/runtime_projection_tests.rs b/src-tauri/src/threads/domain/store/runtime_projection_tests.rs index 4129031f..b7be5df3 100644 --- a/src-tauri/src/threads/domain/store/runtime_projection_tests.rs +++ b/src-tauri/src/threads/domain/store/runtime_projection_tests.rs @@ -98,6 +98,103 @@ fn slim_tool_output_replays_through_the_tool_call_item() { )); } +#[test] +fn failed_tool_output_preserves_its_result_status() { + let item = |item_id: &str, sequence: u64, kind: ThreadItemKind| ThreadItem { + item_id: item_id.to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + parent_item_id: None, + sequence, + created_at: sequence.to_string(), + kind, + }; + let items = vec![ + item( + "call-data-view", + 1, + ThreadItemKind::ToolCallStarted(json!({ + "type": "function_call", + "call_id": "call-data-view", + "name": "publish_data_view", + "arguments": "{}", + })), + ), + item( + "tool-output:call-data-view", + 2, + ThreadItemKind::ToolCallOutput(json!({ + "type": "function_call_output", + "call_id": "call-data-view", + "status": "error", + "output": "publish_data_view cannot be mixed with other tools", + })), + ), + ]; + + let events = runtime_events_from_thread_items(&items, "thread-1", "turn-1"); + assert_eq!(events[1].payload["resultStatus"], "error"); + + let projected = turn_items_from_thread_items(&items, "thread-1", "turn-1"); + assert!(matches!( + &projected[0].data, + AgentTurnItemData::ToolCall { result_status, .. } + if result_status.as_deref() == Some("error") + )); +} + +#[test] +fn artifact_tool_output_replays_the_persisted_envelope() { + let item = |item_id: &str, sequence: u64, kind: ThreadItemKind| ThreadItem { + item_id: item_id.to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + parent_item_id: None, + sequence, + created_at: sequence.to_string(), + kind, + }; + let items = vec![ + item( + "call-data-view", + 1, + ThreadItemKind::ToolCallStarted(json!({ + "type": "custom_tool_call", + "call_id": "call-data-view", + "name": "publish_data_view", + "input": "{}", + })), + ), + item( + "tool-output:call-data-view", + 2, + ThreadItemKind::ToolCallOutput(json!({ + "type": "custom_tool_call_output", + "call_id": "call-data-view", + "output": "Published data view dv_1.", + "tinybot_result": { + "status": "ok", + "summary": "Published data view: Revenue", + "artifacts": [{ + "id": "dv_1", + "kind": "data_view", + "content": { "schemaVersion": "tinybot.data_view.v1" } + }] + } + })), + ), + ]; + + let projected = turn_items_from_thread_items(&items, "thread-1", "turn-1"); + + assert!(matches!( + &projected[0].data, + AgentTurnItemData::ToolCall { result, .. } + if result["artifacts"][0]["id"] == "dv_1" + && result["artifacts"][0]["content"]["schemaVersion"] == "tinybot.data_view.v1" + )); +} + #[test] fn responses_raw_reasoning_stays_hidden_and_tool_output_gets_a_display_summary() { let item = |item_id: &str, sequence: u64, kind: ThreadItemKind| ThreadItem { diff --git a/src-tauri/src/threads/rollout/store/protocol_projection.rs b/src-tauri/src/threads/rollout/store/protocol_projection.rs index 8681cf96..1939cf7c 100644 --- a/src-tauri/src/threads/rollout/store/protocol_projection.rs +++ b/src-tauri/src/threads/rollout/store/protocol_projection.rs @@ -231,6 +231,14 @@ fn project_responses_items(event: &Value) -> ProtocolEventProjection { } fn structured_tool_result(payload: &Value) -> Option { + if let Some(envelope) = payload.get("envelope").filter(|envelope| { + envelope + .get("artifacts") + .and_then(Value::as_array) + .is_some_and(|artifacts| !artifacts.is_empty()) + }) { + return Some(envelope.clone()); + } let result = payload .get("result") .or_else(|| { diff --git a/src-tauri/src/threads/rollout/store/turn_tests.rs b/src-tauri/src/threads/rollout/store/turn_tests.rs index 7ead8780..6b9e9260 100644 --- a/src-tauri/src/threads/rollout/store/turn_tests.rs +++ b/src-tauri/src/threads/rollout/store/turn_tests.rs @@ -62,6 +62,43 @@ fn runtime_tool_events_materialize_a_complete_model_visible_pair() { ); } +#[test] +fn artifact_tool_results_keep_the_envelope_in_the_native_replay_sidecar() { + let result = response_item_from_runtime_event(&json!({ + "eventId": "event-data-view", + "eventName": "agent.tool.result", + "payload": { + "toolCallId": "call-data-view", + "toolName": "publish_data_view", + "resultStatus": "ok", + "content": "Published data view dv_1 as an immutable inline chat artifact.", + "envelope": { + "status": "ok", + "summary": "Published data view: Revenue", + "modelContent": "Published data view dv_1 as an immutable inline chat artifact.", + "structured": { "kind": "data_view_published", "artifactId": "dv_1" }, + "artifacts": [{ + "id": "dv_1", + "kind": "data_view", + "content": { "schemaVersion": "tinybot.data_view.v1" } + }], + "raw": { "artifactId": "dv_1" } + } + } + })) + .expect("artifact tool result should project"); + + assert_eq!(result["tinybot_result"]["status"], "ok"); + assert_eq!( + result["tinybot_result"]["artifacts"][0]["content"]["schemaVersion"], + "tinybot.data_view.v1" + ); + assert_eq!( + result["output"], + "Published data view dv_1 as an immutable inline chat artifact." + ); +} + #[test] fn responses_events_keep_native_output_and_encode_function_results() { let output = response_items_from_runtime_event( diff --git a/src-tauri/src/tools/registry/contributors.rs b/src-tauri/src/tools/registry/contributors.rs index a77fb9ba..540dc459 100644 --- a/src-tauri/src/tools/registry/contributors.rs +++ b/src-tauri/src/tools/registry/contributors.rs @@ -105,7 +105,9 @@ pub(super) fn default_tool_contributors() -> Vec> { let mut runtime_tools = Vec::new(); for entry in core_tool_entries() { match entry.namespace.as_str() { - "tool_registry" | "interaction" | "planning" => control_tools.push(entry), + "tool_registry" | "interaction" | "planning" | "presentation" => { + control_tools.push(entry) + } "shell" | "subagent" => runtime_tools.push(entry), namespace => panic!( "core tool `{}` has no contributor for namespace `{namespace}`", diff --git a/src-tauri/src/tools/registry/mod.rs b/src-tauri/src/tools/registry/mod.rs index f205f40f..6446fd30 100644 --- a/src-tauri/src/tools/registry/mod.rs +++ b/src-tauri/src/tools/registry/mod.rs @@ -11,6 +11,7 @@ use std::sync::Arc; pub const TOOL_SEARCH_METHOD: &str = "tool_search"; pub const REQUEST_USER_INPUT_METHOD: &str = "request_user_input"; +pub const PUBLISH_DATA_VIEW_METHOD: &str = "publish_data_view"; pub const UPDATE_PLAN_METHOD: &str = "update_plan"; pub const DEFAULT_TOOL_SEARCH_LIMIT: usize = 5; pub const MAX_TOOL_SEARCH_LIMIT: usize = 20; @@ -88,6 +89,7 @@ pub enum ToolExecutionTarget { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ToolRuntimeControl { ToolSearch, + PublishDataView, RequestUserInput, UpdatePlan, } @@ -376,6 +378,90 @@ fn core_tool_entries() -> Vec { "additionalProperties": false }), ), + runtime_control_tool( + PUBLISH_DATA_VIEW_METHOD, + "presentation", + "Publish data view", + "Publish a validated chart, KPI summary, waterfall, or exact data table as an inline chat artifact when a visual makes the analysis materially easier to understand. Submit typed rows, semantic encodings, reporting units, and provenance. Use the smallest useful view and do not publish decorative or redundant charts. The runtime owns rendering; never include HTML, JavaScript, SVG, callbacks, or renderer-specific options.", + ToolRuntimeControl::PublishDataView, + runtime_policy(false, ToolCancellationMode::Cooperative, false, true), + vec![WorkerCapability::SessionWrite], + json!({ + "type": "object", + "required": ["schemaVersion", "title", "insight", "dataset", "view", "provenance"], + "properties": { + "schemaVersion": { "type": "string", "const": "tinybot.data_view.v1" }, + "title": { "type": "string", "minLength": 1, "maxLength": 160 }, + "insight": { "type": "string", "minLength": 1, "maxLength": 500 }, + "dataset": { + "type": "object", + "required": ["columns", "rows"], + "properties": { + "columns": { + "type": "array", "minItems": 1, "maxItems": 20, + "items": { + "type": "object", "required": ["key", "label", "type"], + "properties": { + "key": { "type": "string" }, + "label": { "type": "string" }, + "type": { "type": "string", "enum": ["category", "string", "number", "date", "datetime", "boolean"] }, + "format": { "type": "string", "enum": ["number", "integer", "compact", "percent", "currency"] }, + "currency": { "type": "string", "description": "Three-letter ISO 4217 code, for example USD." }, + "unit": { "type": "string", "description": "Display unit only; values must already use this unit." }, + "fractionDigits": { "type": "integer", "minimum": 0, "maximum": 4 } + }, + "additionalProperties": false + } + }, + "rows": { + "type": "array", "minItems": 1, "maxItems": 1000, + "items": { + "type": "object", "required": ["id", "values"], + "properties": { + "id": { "type": "string" }, + "values": { "type": "object", "description": "Keys must match declared columns; values must match column types. Null is allowed for missing data." }, + "sourceIds": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "view": { + "type": "object", + "required": ["kind"], + "description": "metrics: items[{field,comparisonField?,direction?}]; table: fields? and defaultSort?; cartesian: x, series[{field,mark:line|bar|area,axis:left|right?}], stack:none|normal?; waterfall: category, value, totalField?. At most six metrics or series." + }, + "provenance": { + "type": "object", "required": ["status"], + "properties": { + "status": { "type": "string", "enum": ["sourced", "user_provided", "unsourced"] }, + "asOf": { "type": "string" }, + "sources": { + "type": "array", "maxItems": 64, + "items": { + "type": "object", "required": ["id", "kind", "title"], + "properties": { + "id": { "type": "string" }, + "kind": { "type": "string", "enum": ["url", "file", "user_input"] }, + "title": { "type": "string" }, + "uri": { "type": "string" }, + "locator": { "type": "string" }, + "publishedAt": { "type": "string" } + }, + "additionalProperties": false + } + }, + "methodology": { "type": "string" }, + "caveats": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }), + ), runtime_control_tool( REQUEST_USER_INPUT_METHOD, "interaction", diff --git a/src-tauri/src/tools/registry/registry_tests.rs b/src-tauri/src/tools/registry/registry_tests.rs index ec61291c..3c51a3d8 100644 --- a/src-tauri/src/tools/registry/registry_tests.rs +++ b/src-tauri/src/tools/registry/registry_tests.rs @@ -60,6 +60,32 @@ fn request_user_input_requires_form_capability() { ); } +#[test] +fn publish_data_view_is_model_visible_and_requires_session_write() { + let denied = WorkerToolRegistryRpc::new(CapabilityPolicy::default()) + .get_tool(PUBLISH_DATA_VIEW_METHOD) + .expect("publish_data_view should be registered"); + let available = + WorkerToolRegistryRpc::new(CapabilityPolicy::new([WorkerCapability::SessionWrite])) + .get_tool(PUBLISH_DATA_VIEW_METHOD) + .expect("publish_data_view should be registered"); + + assert_eq!(available.exposure, ToolExposure::Model); + assert!(!denied.available); + assert!(available.available); + assert!(available.runtime_policy.mutates_session); + assert!(!available.runtime_policy.mutates_workspace); + assert!(!available.supports_parallel_tool_calls); + assert_eq!( + available.execution_target, + ToolExecutionTarget::RuntimeControl(ToolRuntimeControl::PublishDataView) + ); + assert_eq!( + available.input_schema["properties"]["schemaVersion"]["const"], + "tinybot.data_view.v1" + ); +} + #[test] fn canonical_apply_patch_is_model_visible_and_legacy_name_is_hidden() { let registry = WorkerToolRegistryRpc::new(CapabilityPolicy::new([ diff --git a/src/app-core/chat/chatTurnModel.test.ts b/src/app-core/chat/chatTurnModel.test.ts index 6424a7d8..a4c38ca6 100644 --- a/src/app-core/chat/chatTurnModel.test.ts +++ b/src/app-core/chat/chatTurnModel.test.ts @@ -103,6 +103,79 @@ describe("chat turn model", () => { }); }); + test("projects a persisted data view artifact from a canonical tool result", () => { + const content = { + schemaVersion: "tinybot.data_view.v1", + title: "Quarterly revenue", + insight: "Revenue increased in Q2.", + dataset: { + columns: [ + { key: "quarter", label: "Quarter", type: "category" }, + { key: "revenue", label: "Revenue", type: "number" }, + ], + rows: [ + { id: "q1", values: { quarter: "Q1", revenue: 100 } }, + { id: "q2", values: { quarter: "Q2", revenue: 120 } }, + ], + }, + view: { kind: "cartesian", x: "quarter", series: [{ field: "revenue", mark: "bar" }] }, + provenance: { status: "user_provided", sources: [], caveats: [] }, + }; + const runtimeState = normalizeAgentTurnRuntimeStatePayload(canonicalRuntimeState("turn-chart", [{ + itemId: "call-chart", + kind: "tool_call", + status: "completed", + title: "publish_data_view", + data: { + type: "tool_call", + toolCallId: "call-chart", + name: "publish_data_view", + status: "completed", + args: {}, + result: { + status: "ok", + artifacts: [{ id: "dv_1", kind: "data_view", title: content.title, content }], + }, + timing: {}, + }, + }])); + + const [turn] = backendRuntimeStatesToTurns("WebSocket:chat-1", [runtimeState]); + + expect(turn.steps[0].artifacts?.[0]).toMatchObject({ + id: "dv_1", + kind: "data_view", + dataView: { title: "Quarterly revenue", view: { kind: "cartesian" } }, + }); + }); + + test("projects a failed data view result as a failed tool step", () => { + const runtimeState = normalizeAgentTurnRuntimeStatePayload(canonicalRuntimeState("turn-chart-error", [{ + itemId: "call-chart-error", + kind: "tool_call", + status: "completed", + title: "publish_data_view", + data: { + type: "tool_call", + toolCallId: "call-chart-error", + name: "publish_data_view", + status: "completed", + resultStatus: "error", + args: {}, + result: "publish_data_view cannot be mixed with other tools", + timing: {}, + }, + }])); + + const [turn] = backendRuntimeStatesToTurns("WebSocket:chat-1", [runtimeState]); + + expect(turn.steps[0]).toMatchObject({ + kind: "tool_call", + status: "failed", + toolCall: { name: "publish_data_view" }, + }); + }); + test("restores completed assistant messages from the canonical timeline", () => { const runtimeState = normalizeAgentTurnRuntimeStatePayload(canonicalRuntimeState("turn-completed", [ { diff --git a/src/app-core/chat/chatTurnModel.ts b/src/app-core/chat/chatTurnModel.ts index e442cb23..0d03fc3f 100644 --- a/src/app-core/chat/chatTurnModel.ts +++ b/src/app-core/chat/chatTurnModel.ts @@ -1,10 +1,12 @@ import type { AgentInputReference } from "./agentInputReference"; +import { parseDataViewDocument, type DataViewDocument } from "./dataView"; export type ChatTurnStatus = "pending" | "running" | "awaiting_user" | "completed" | "failed" | "interrupted"; export type ChatStepStatus = "pending" | "running" | "blocked" | "completed" | "failed" | "cancelled"; export type AssistantMessagePhase = "unknown" | "commentary" | "final_answer"; export type AgentContextType = "main" | "spawn" | "subagent" | "team"; export type ArtifactKind = + | "data_view" | "terminal_output" | "file_diff" | "browser_snapshot" @@ -21,6 +23,8 @@ export type AgentContext = { }; export type ArtifactRef = { + dataView?: DataViewDocument; + dataViewError?: string; fetchPath?: string; id: string; kind: ArtifactKind | string; @@ -29,6 +33,7 @@ export type ArtifactRef = { sizeBytes?: number; status?: string; title: string; + warnings?: string[]; }; export type TokenUsage = { @@ -106,6 +111,7 @@ export type DelegatedAgentTraceState = { }; export type LoadedArtifactDetail = { + dataView?: DataViewDocument; id: string; imageDataUrl?: string; mimeType?: string; @@ -128,8 +134,12 @@ export function projectLoadedArtifactDetail( } const content = stringValue(artifact.content ?? artifact.preview); const mimeType = stringValue(artifact.mimeType ?? artifact.mime_type) || reference.mimeType; + const dataView = reference.kind === "data_view" + ? parseDataViewDocument(artifact.content ?? reference.dataView) + : undefined; const imageDataUrl = safeRasterImageDataUrl(content); return { + ...(dataView ? { dataView } : {}), id, ...(imageDataUrl ? { imageDataUrl } : {}), ...(mimeType ? { mimeType } : {}), @@ -623,9 +633,13 @@ function applyTurnItemToTurn(turn: ChatTurn, item: BackendAgentTurnItem): void { } if (item.kind === "tool_call") { const toolCall = toolCallFromRuntimeItem(item); + const envelope = recordValue(item.data.result); + const resultStatus = stringValue(item.data.resultStatus ?? envelope.status); + const toolStatus = resultStatus === "error" || resultStatus === "denied" ? "failed" : status; turn.steps.push(runtimeStep(item, sequence, { + artifacts: artifactArray(envelope.artifacts), kind: "tool_call", - status, + status: toolStatus, title: item.title || toolCall.name, toolCall, })); @@ -993,15 +1007,30 @@ export function sanitizeTextPreview(value: string): string { function artifactFromPayload(value: unknown): ArtifactRef { const payload = recordValue(value); const fetchPath = stringValue(payload.fetch_path ?? payload.fetchPath); + const kind = stringValue(payload.kind) || "text"; + let dataView: DataViewDocument | undefined; + let dataViewError: string | undefined; + if (kind === "data_view") { + try { + dataView = parseDataViewDocument(payload.content); + } catch (error) { + dataViewError = error instanceof Error ? error.message : String(error); + } + } return { + ...(dataView ? { dataView } : {}), + ...(dataViewError ? { dataViewError } : {}), ...(fetchPath ? { fetchPath } : {}), id: stringValue(payload.id ?? payload.artifact_id) || "artifact", - kind: stringValue(payload.kind) || "text", + kind, mimeType: stringValue(payload.mime_type ?? payload.mimeType), preview: safeArtifactText(stringValue(payload.preview)), sizeBytes: numberValue(payload.size_bytes ?? payload.sizeBytes), status: stringValue(payload.status) || "available", title: stringValue(payload.title) || stringValue(payload.id ?? payload.artifact_id) || "Artifact", + warnings: Array.isArray(payload.warnings) + ? payload.warnings.map(stringValue).filter(Boolean) + : undefined, }; } diff --git a/src/app-core/chat/dataView.test.ts b/src/app-core/chat/dataView.test.ts new file mode 100644 index 00000000..b282ba2f --- /dev/null +++ b/src/app-core/chat/dataView.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "vitest"; +import { dataViewToCsv, formatDataViewCell, parseDataViewDocument } from "./dataView"; + +function validView() { + return { + schemaVersion: "tinybot.data_view.v1", + title: "Revenue and growth", + insight: "Revenue rose while growth slowed.", + dataset: { + columns: [ + { key: "period", label: "Period", type: "category" }, + { key: "revenue", label: "Revenue", type: "number", format: "currency", currency: "USD", unit: "million", fractionDigits: 0 }, + { key: "growth", label: "Growth", type: "number", format: "percent", fractionDigits: 1 }, + ], + rows: [ + { id: "fy24", values: { period: "FY2024", revenue: 391035, growth: 9.4 }, sourceIds: ["filing"] }, + { id: "fy25", values: { period: "FY2025", revenue: 403155, growth: 3.1 }, sourceIds: ["filing"] }, + ], + }, + view: { + kind: "cartesian", + x: "period", + series: [ + { field: "revenue", mark: "bar", axis: "left" }, + { field: "growth", mark: "line", axis: "right" }, + ], + stack: "none", + }, + provenance: { + status: "sourced", + asOf: "2025-09-27", + sources: [{ id: "filing", kind: "url", title: "FY2025 Form 10-K", uri: "https://example.com/filing" }], + methodology: "Reported annual revenue.", + caveats: [], + }, + }; +} + +describe("data view contract", () => { + test("parses a mixed chart and exports raw rows in declared column order", () => { + const document = parseDataViewDocument(validView()); + + expect(document.view.kind).toBe("cartesian"); + expect(dataViewToCsv(document)).toBe( + "\uFEFFPeriod,Revenue,Growth\r\nFY2024,391035,9.4\r\nFY2025,403155,3.1", + ); + expect(formatDataViewCell(document.dataset.columns[2], 3.1, "en-US")).toBe("3.1%"); + }); + + test("parses persisted artifacts whose absent optional fields were serialized as null", () => { + const document = parseDataViewDocument({ + schemaVersion: "tinybot.data_view.v1", + title: "Channel share", + insight: "Online leads the sample mix.", + dataset: { + columns: [ + { key: "channel", label: "Channel", type: "category", format: null, currency: null, unit: null, fractionDigits: null }, + { key: "share", label: "Share", type: "number", format: "percent", currency: null, unit: null, fractionDigits: 1 }, + ], + rows: [{ id: "online", sourceIds: [], values: { channel: "Online", share: 38 } }], + }, + view: { + kind: "metrics", + items: [{ field: "share", comparisonField: null, direction: null }], + }, + provenance: { + status: "unsourced", + asOf: null, + sources: [], + methodology: null, + caveats: [], + }, + }); + + expect(document).toMatchObject({ + title: "Channel share", + view: { kind: "metrics", items: [{ field: "share" }] }, + }); + expect(document.dataset.columns[0]).toEqual({ key: "channel", label: "Channel", type: "category" }); + }); + + test("rejects persisted content with unknown row fields", () => { + const input = validView(); + (input.dataset.rows[0].values as Record).invented = 42; + + expect(() => parseDataViewDocument(input)).toThrow("unknown field invented"); + }); + + test("rejects unsafe source schemes", () => { + const input = validView(); + input.provenance.sources[0].uri = "javascript:alert(1)"; + + expect(() => parseDataViewDocument(input)).toThrow("http or https"); + }); +}); diff --git a/src/app-core/chat/dataView.ts b/src/app-core/chat/dataView.ts new file mode 100644 index 00000000..98ce0e8d --- /dev/null +++ b/src/app-core/chat/dataView.ts @@ -0,0 +1,410 @@ +export const DATA_VIEW_SCHEMA_VERSION = "tinybot.data_view.v1" as const; + +export type DataViewColumnType = "category" | "string" | "number" | "date" | "datetime" | "boolean"; +export type DataViewNumberFormat = "number" | "integer" | "compact" | "percent" | "currency"; +export type DataViewCell = string | number | boolean | null; + +export type DataViewColumn = { + key: string; + label: string; + type: DataViewColumnType; + format?: DataViewNumberFormat; + currency?: string; + unit?: string; + fractionDigits?: number; +}; + +export type DataViewRow = { + id: string; + values: Record; + sourceIds?: string[]; +}; + +export type DataViewMetric = { + field: string; + comparisonField?: string; + direction?: "higher_is_better" | "lower_is_better" | "neutral"; +}; + +export type DataViewSeries = { + field: string; + mark: "line" | "bar" | "area"; + axis?: "left" | "right"; +}; + +export type DataViewView = + | { kind: "metrics"; items: DataViewMetric[] } + | { kind: "table"; fields?: string[]; defaultSort?: { field: string; direction: "asc" | "desc" } } + | { kind: "cartesian"; x: string; series: DataViewSeries[]; stack?: "none" | "normal" } + | { kind: "waterfall"; category: string; value: string; totalField?: string }; + +export type DataViewSource = { + id: string; + kind: "url" | "file" | "user_input"; + title: string; + uri?: string; + locator?: string; + publishedAt?: string; +}; + +export type DataViewDocument = { + schemaVersion: typeof DATA_VIEW_SCHEMA_VERSION; + title: string; + insight: string; + dataset: { columns: DataViewColumn[]; rows: DataViewRow[] }; + view: DataViewView; + provenance: { + status: "sourced" | "user_provided" | "unsourced"; + asOf?: string; + sources: DataViewSource[]; + methodology?: string; + caveats: string[]; + }; +}; + +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_.-]*$/; +const COLUMN_TYPES = new Set(["category", "string", "number", "date", "datetime", "boolean"]); +const NUMBER_FORMATS = new Set(["number", "integer", "compact", "percent", "currency"]); + +export function parseDataViewDocument(value: unknown): DataViewDocument { + const root = objectValue(value, "data view"); + if (root.schemaVersion !== DATA_VIEW_SCHEMA_VERSION) { + throw new Error(`Unsupported data view schema: ${stringValue(root.schemaVersion) || "missing"}.`); + } + const title = requiredText(root.title, "title", 160); + const insight = requiredText(root.insight, "insight", 500); + const dataset = objectValue(root.dataset, "dataset"); + const rawColumns = arrayValue(dataset.columns, "dataset.columns", 1, 20); + const rawRows = arrayValue(dataset.rows, "dataset.rows", 1, 1_000); + const columns = rawColumns.map(parseColumn); + assertUnique(columns.map((column) => column.key), "column key"); + const columnsByKey = new Map(columns.map((column) => [column.key, column])); + const provenance = parseProvenance(root.provenance); + const sourceIds = new Set(provenance.sources.map((source) => source.id)); + const rows = rawRows.map((row) => parseRow(row, columnsByKey, sourceIds)); + assertUnique(rows.map((row) => row.id), "row id"); + const view = parseView(root.view, columnsByKey); + return { + schemaVersion: DATA_VIEW_SCHEMA_VERSION, + title, + insight, + dataset: { columns, rows }, + view, + provenance, + }; +} + +function parseColumn(value: unknown): DataViewColumn { + const raw = objectValue(value, "column"); + const key = identifierValue(raw.key, "column key"); + const label = requiredText(raw.label, `column ${key} label`, 120); + const type = stringValue(raw.type) as DataViewColumnType; + if (!COLUMN_TYPES.has(type)) { + throw new Error(`Column ${key} has unsupported type.`); + } + const format = stringValue(raw.format) as DataViewNumberFormat | ""; + if (format && !NUMBER_FORMATS.has(format)) { + throw new Error(`Column ${key} has unsupported number format.`); + } + const fractionDigits = isAbsent(raw.fractionDigits) ? undefined : numberValue(raw.fractionDigits, `${key}.fractionDigits`); + if (fractionDigits !== undefined && (!Number.isInteger(fractionDigits) || fractionDigits < 0 || fractionDigits > 4)) { + throw new Error(`Column ${key} fractionDigits is invalid.`); + } + if (type !== "number" && (format || !isAbsent(raw.currency) || !isAbsent(raw.unit) || fractionDigits !== undefined)) { + throw new Error(`Non-number column ${key} contains numeric formatting.`); + } + const currency = optionalText(raw.currency, 3); + if (format === "currency" && !/^[A-Z]{3}$/.test(currency ?? "")) { + throw new Error(`Currency column ${key} requires an uppercase ISO code.`); + } + return { + key, + label, + type, + ...(format ? { format } : {}), + ...(currency ? { currency } : {}), + ...(optionalText(raw.unit, 64) ? { unit: optionalText(raw.unit, 64) } : {}), + ...(fractionDigits !== undefined ? { fractionDigits } : {}), + }; +} + +function parseRow( + value: unknown, + columns: Map, + sourceIds: Set, +): DataViewRow { + const raw = objectValue(value, "row"); + const id = identifierValue(raw.id, "row id"); + const rawValues = objectValue(raw.values, `row ${id} values`); + const values: Record = {}; + for (const [key, cell] of Object.entries(rawValues)) { + const column = columns.get(key); + if (!column) { + throw new Error(`Row ${id} contains unknown field ${key}.`); + } + if (!validCell(column.type, cell)) { + throw new Error(`Row ${id} field ${key} does not match its declared type.`); + } + values[key] = cell as DataViewCell; + } + const rowSourceIds = raw.sourceIds === undefined + ? undefined + : arrayValue(raw.sourceIds, `row ${id} sourceIds`, 0, 64).map((sourceId) => identifierValue(sourceId, "source id")); + if (rowSourceIds?.some((sourceId) => !sourceIds.has(sourceId))) { + throw new Error(`Row ${id} references an unknown source.`); + } + return { id, values, ...(rowSourceIds?.length ? { sourceIds: rowSourceIds } : {}) }; +} + +function parseView(value: unknown, columns: Map): DataViewView { + const raw = objectValue(value, "view"); + const kind = stringValue(raw.kind); + if (kind === "metrics") { + const items = arrayValue(raw.items, "view.items", 1, 6).map((value) => { + const item = objectValue(value, "metric item"); + const field = numericField(item.field, columns); + const comparisonField = isAbsent(item.comparisonField) ? undefined : numericField(item.comparisonField, columns); + const direction = stringValue(item.direction) as DataViewMetric["direction"] | ""; + if (direction && !["higher_is_better", "lower_is_better", "neutral"].includes(direction)) { + throw new Error(`Metric ${field} has invalid direction.`); + } + return { field, ...(comparisonField ? { comparisonField } : {}), ...(direction ? { direction } : {}) }; + }); + return { kind, items }; + } + if (kind === "table") { + const fields = raw.fields === undefined + ? undefined + : arrayValue(raw.fields, "view.fields", 0, 20).map((field) => knownField(field, columns)); + const defaultSort = isAbsent(raw.defaultSort) ? undefined : objectValue(raw.defaultSort, "view.defaultSort"); + const direction = stringValue(defaultSort?.direction); + if (defaultSort && !["asc", "desc"].includes(direction)) { + throw new Error("Table defaultSort direction is invalid."); + } + return { + kind, + ...(fields?.length ? { fields } : {}), + ...(defaultSort ? { defaultSort: { field: knownField(defaultSort.field, columns), direction: direction as "asc" | "desc" } } : {}), + }; + } + if (kind === "cartesian") { + const x = knownField(raw.x, columns); + if (!new Set(["category", "string", "date", "datetime"]).has(columns.get(x)!.type)) { + throw new Error(`Cartesian x field ${x} has an incompatible type.`); + } + const series = arrayValue(raw.series, "view.series", 1, 6).map((value) => { + const item = objectValue(value, "series"); + const field = numericField(item.field, columns); + const mark = stringValue(item.mark) as DataViewSeries["mark"]; + const axis = stringValue(item.axis) as DataViewSeries["axis"] | ""; + if (!new Set(["line", "bar", "area"]).has(mark)) { + throw new Error(`Series ${field} has unsupported mark.`); + } + if (axis && !new Set(["left", "right"]).has(axis)) { + throw new Error(`Series ${field} has unsupported axis.`); + } + return { field, mark, ...(axis ? { axis } : {}) }; + }); + const stack = stringValue(raw.stack) as "none" | "normal" | ""; + if (stack && !["none", "normal"].includes(stack)) { + throw new Error("Cartesian stack is invalid."); + } + return { kind, x, series, ...(stack ? { stack } : {}) }; + } + if (kind === "waterfall") { + const category = knownField(raw.category, columns); + const categoryType = columns.get(category)!.type; + if (categoryType !== "category" && categoryType !== "string") { + throw new Error(`Waterfall category ${category} has an incompatible type.`); + } + const totalField = isAbsent(raw.totalField) ? undefined : knownField(raw.totalField, columns); + if (totalField && columns.get(totalField)!.type !== "boolean") { + throw new Error(`Waterfall total field ${totalField} must be boolean.`); + } + return { kind, category, value: numericField(raw.value, columns), ...(totalField ? { totalField } : {}) }; + } + throw new Error(`Unsupported data view kind: ${kind || "missing"}.`); +} + +function parseProvenance(value: unknown): DataViewDocument["provenance"] { + const raw = objectValue(value, "provenance"); + const status = stringValue(raw.status) as DataViewDocument["provenance"]["status"]; + if (!new Set(["sourced", "user_provided", "unsourced"]).has(status)) { + throw new Error("Data view provenance status is invalid."); + } + const sources = raw.sources === undefined + ? [] + : arrayValue(raw.sources, "provenance.sources", 0, 64).map((value) => { + const source = objectValue(value, "source"); + const kind = stringValue(source.kind) as DataViewSource["kind"]; + if (!new Set(["url", "file", "user_input"]).has(kind)) { + throw new Error("Data view source kind is invalid."); + } + const uri = optionalText(source.uri, 2_048); + if (kind === "url" && (!uri || !/^https?:\/\//i.test(uri))) { + throw new Error("URL source requires an http or https URI."); + } + return { + id: identifierValue(source.id, "source id"), + kind, + title: requiredText(source.title, "source title", 240), + ...(uri ? { uri } : {}), + ...(optionalText(source.locator, 500) ? { locator: optionalText(source.locator, 500) } : {}), + ...(optionalText(source.publishedAt, 64) ? { publishedAt: optionalText(source.publishedAt, 64) } : {}), + }; + }); + assertUnique(sources.map((source) => source.id), "source id"); + if (status === "sourced" && sources.length === 0) { + throw new Error("Sourced data view has no sources."); + } + const caveats = raw.caveats === undefined + ? [] + : arrayValue(raw.caveats, "provenance.caveats", 0, 64).map((item) => requiredText(item, "caveat", 1_000)); + return { + status, + sources, + ...(optionalText(raw.asOf, 64) ? { asOf: optionalText(raw.asOf, 64) } : {}), + ...(optionalText(raw.methodology, 2_000) ? { methodology: optionalText(raw.methodology, 2_000) } : {}), + caveats, + }; +} + +export function dataViewToCsv(document: DataViewDocument): string { + const columns = document.dataset.columns; + const lines = [columns.map((column) => csvCell(column.label)).join(",")]; + for (const row of document.dataset.rows) { + lines.push(columns.map((column) => csvCell(row.values[column.key])).join(",")); + } + return `\uFEFF${lines.join("\r\n")}`; +} + +export function formatDataViewCell(column: DataViewColumn, value: DataViewCell | undefined, locale?: string): string { + if (value === null || value === undefined) { + return "—"; + } + if (column.type !== "number" || typeof value !== "number") { + if (column.type === "boolean") { + return value ? "Yes" : "No"; + } + return String(value); + } + const maximumFractionDigits = column.fractionDigits ?? (column.format === "integer" ? 0 : 2); + let formatted: string; + if (column.format === "percent") { + formatted = `${new Intl.NumberFormat(locale, { maximumFractionDigits }).format(value)}%`; + } else if (column.format === "currency" && column.currency) { + formatted = new Intl.NumberFormat(locale, { + style: "currency", + currency: column.currency, + maximumFractionDigits, + }).format(value); + } else { + formatted = new Intl.NumberFormat(locale, { + ...(column.format === "compact" ? { notation: "compact" as const } : {}), + maximumFractionDigits, + }).format(value); + } + return column.unit ? `${formatted} ${column.unit}` : formatted; +} + +function csvCell(value: DataViewCell | undefined): string { + const text = value === null || value === undefined ? "" : String(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function validCell(type: DataViewColumnType, value: unknown): boolean { + if (value === null) { + return true; + } + if (type === "number") { + return typeof value === "number" && Number.isFinite(value); + } + if (type === "boolean") { + return typeof value === "boolean"; + } + if (typeof value !== "string") { + return false; + } + if (type === "date") { + return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)); + } + if (type === "datetime") { + return !Number.isNaN(Date.parse(value)); + } + return true; +} + +function knownField(value: unknown, columns: Map): string { + const field = identifierValue(value, "view field"); + if (!columns.has(field)) { + throw new Error(`View references unknown field ${field}.`); + } + return field; +} + +function numericField(value: unknown, columns: Map): string { + const field = knownField(value, columns); + if (columns.get(field)!.type !== "number") { + throw new Error(`View field ${field} must be numeric.`); + } + return field; +} + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value as Record; +} + +function arrayValue(value: unknown, label: string, min: number, max: number): unknown[] { + if (!Array.isArray(value) || value.length < min || value.length > max) { + throw new Error(`${label} must contain between ${min} and ${max} items.`); + } + return value; +} + +function requiredText(value: unknown, label: string, max: number): string { + const text = stringValue(value).trim(); + if (!text || [...text].length > max) { + throw new Error(`${label} must contain between 1 and ${max} characters.`); + } + return text; +} + +function optionalText(value: unknown, max: number): string | undefined { + if (isAbsent(value)) { + return undefined; + } + const text = requiredText(value, "text", max); + return text || undefined; +} + +function isAbsent(value: unknown): value is null | undefined { + return value === undefined || value === null; +} + +function identifierValue(value: unknown, label: string): string { + const identifier = stringValue(value); + if (!IDENTIFIER.test(identifier)) { + throw new Error(`${label} is invalid.`); + } + return identifier; +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } + return value; +} + +function assertUnique(values: string[], label: string): void { + if (new Set(values).size !== values.length) { + throw new Error(`Duplicate ${label}.`); + } +} diff --git a/src/react-workbench/chat/ChatPage.test.tsx b/src/react-workbench/chat/ChatPage.test.tsx index 5d131972..49d7d190 100644 --- a/src/react-workbench/chat/ChatPage.test.tsx +++ b/src/react-workbench/chat/ChatPage.test.tsx @@ -1950,6 +1950,7 @@ describe("ChatPage", () => { const drawer = screen.getByLabelText("Details drawer"); expect(drawer.getAttribute("data-motion")).toBe("fade-content"); expect(drawer.getAttribute("data-state")).toBe("open"); + expect(drawer.firstElementChild?.classList.contains("react-right-drawer__header")).toBe(true); expect(drawer.textContent).toContain("Done"); }); diff --git a/src/react-workbench/chat/ChatPage.tsx b/src/react-workbench/chat/ChatPage.tsx index ca41c0f7..ac1df281 100644 --- a/src/react-workbench/chat/ChatPage.tsx +++ b/src/react-workbench/chat/ChatPage.tsx @@ -71,6 +71,7 @@ import { AgentUiFormCard } from "./AgentUiFormCard"; import { AssistantMarkdown } from "./AssistantMarkdown"; import { isApplyPatchToolCall, PatchDiffCard, patchChangeSetFromToolResult } from "./PatchDiffCard"; import { ToolActivityItem } from "./ToolActivityItem"; +import { DataViewCard } from "./DataViewCard"; import { clampTinyOsWidth, LiveCanvas, type LiveCanvasEntry, type LiveCanvasMode } from "./LiveCanvas"; import { SessionTabStrip, type SessionTabItem } from "./SessionTabStrip"; import { @@ -1910,6 +1911,17 @@ export function ChatPage({ if (!activeSession) { return; } + if (artifact.kind === "data_view") { + setDrawer({ + kind: "artifact", + title: artifact.title, + artifact, + ...(artifact.dataView ? { detail: { id: artifact.id, title: artifact.title, mimeType: artifact.mimeType, dataView: artifact.dataView } } : {}), + loading: false, + ...(artifact.dataViewError ? { error: artifact.dataViewError } : {}), + }); + return; + } setDrawer({ kind: "artifact", title: artifact.title, artifact, loading: Boolean(chatStore.loadArtifact) }); if (!chatStore.loadArtifact) { return; @@ -2528,21 +2540,23 @@ export function ChatPage({ {drawer ? ( ) : null} @@ -3166,6 +3180,8 @@ function CanonicalChatTurn({ && step.kind !== "error" && !(step.kind === "form" && step.form && interactiveFormIds.has(step.form.formId)) )); + const dataViewArtifacts = uniqueArtifacts(executionItems.flatMap((step) => step.artifacts ?? [])) + .filter((artifact) => artifact.kind === "data_view"); const hasUserMessage = Boolean(turn.userMessage.text.trim() || turn.userMessage.references?.length); return (
@@ -3240,6 +3256,9 @@ function CanonicalChatTurn({ text="" /> ) : null} + {dataViewArtifacts.map((artifact) => ( + + ))}
); } @@ -3790,12 +3809,13 @@ function formatPlanStepStatus(status: PlanStepStatus, t: TFunction<"chat">): str function CanonicalArtifacts({ artifacts, onOpen }: { artifacts: ArtifactRef[]; onOpen: (artifact: ArtifactRef) => void }) { const { t } = useTranslation("chat"); - if (!artifacts.length) { + const visibleArtifacts = artifacts.filter((artifact) => artifact.kind !== "data_view"); + if (!visibleArtifacts.length) { return null; } return (
    - {artifacts.map((artifact) => ( + {visibleArtifacts.map((artifact) => (
  • @@ -3804,6 +3824,10 @@ function CanonicalArtifacts({ artifacts, onOpen }: { artifacts: ArtifactRef[]; o ); } +function uniqueArtifacts(artifacts: ArtifactRef[]): ArtifactRef[] { + return [...new Map(artifacts.map((artifact) => [artifact.id, artifact])).values()]; +} + function CanonicalScopedErrors({ errors }: { errors: NonNullable }) { if (!errors.length) { return null; @@ -4336,8 +4360,9 @@ function ArtifactDetails({ {loading ?

    {t("details.loadingArtifact")}

    : null} {error ?

    {error}

    : null} {detail?.imageDataUrl ? {detail.title} : null} + {detail?.dataView ? : null} {detail?.textContent ?
    {detail.textContent}
    : null} - {!loading && !error && !detail?.imageDataUrl && !detail?.textContent ?

    {t("details.noPreview")}

    : null} + {!loading && !error && !detail?.dataView && !detail?.imageDataUrl && !detail?.textContent ?

    {t("details.noPreview")}

    : null} ); } diff --git a/src/react-workbench/chat/DataViewCard.tsx b/src/react-workbench/chat/DataViewCard.tsx new file mode 100644 index 00000000..d2e72bbf --- /dev/null +++ b/src/react-workbench/chat/DataViewCard.tsx @@ -0,0 +1,357 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { BarChart3, Download, Maximize2, ShieldAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import * as echarts from "echarts/core"; +import { BarChart, LineChart } from "echarts/charts"; +import { + AriaComponent, + DatasetComponent, + GridComponent, + LegendComponent, + TooltipComponent, +} from "echarts/components"; +import { CanvasRenderer } from "echarts/renderers"; +import type { EChartsCoreOption, EChartsType } from "echarts/core"; +import { + dataViewToCsv, + formatDataViewCell, + type DataViewColumn, + type DataViewDocument, + type DataViewRow, +} from "../../app-core/chat/dataView"; +import type { ArtifactRef } from "../../app-core/chat/chatTurnModel"; + +echarts.use([ + AriaComponent, + BarChart, + CanvasRenderer, + DatasetComponent, + GridComponent, + LegendComponent, + LineChart, + TooltipComponent, +]); + +export function DataViewCard({ + artifact, + expanded = false, + onOpen, +}: { + artifact: ArtifactRef; + expanded?: boolean; + onOpen?: (artifact: ArtifactRef) => void; +}) { + const { i18n, t } = useTranslation("chat"); + const document = artifact.dataView; + const chartAvailable = document ? document.view.kind === "cartesian" || document.view.kind === "waterfall" : false; + const [activeTab, setActiveTab] = useState<"chart" | "data">(chartAvailable ? "chart" : "data"); + + useEffect(() => { + setActiveTab(chartAvailable ? "chart" : "data"); + }, [artifact.id, chartAvailable]); + + if (!document) { + return ( +
    +
    + +

    {artifact.title}

    {t("dataView.invalid")}

    +
    + {artifact.dataViewError ?

    {artifact.dataViewError}

    : null} +
    + ); + } + + const locale = i18n.resolvedLanguage; + return ( +
    +
    + +
    +

    {document.title}

    +

    {document.insight}

    +
    +
    + {!expanded && onOpen ? ( + + ) : null} + +
    +
    + + {chartAvailable ? ( +
    + + +
    + ) : null} + +
    + {activeTab === "chart" && chartAvailable ? ( + + ) : document.view.kind === "metrics" ? ( + + ) : ( + + )} +
    + +
    + + {document.provenance.status === "sourced" + ? t("dataView.sourced") + : document.provenance.status === "user_provided" + ? t("dataView.userProvided") + : t("dataView.unsourced")} + + {document.provenance.asOf ? {t("dataView.asOf", { date: document.provenance.asOf })} : null} + {t("dataView.dimensions", { rows: document.dataset.rows.length, columns: document.dataset.columns.length })} +
    + + {expanded ? : null} +
    + ); +} + +function DataViewChart({ document }: { document: DataViewDocument }) { + const { t } = useTranslation("chat"); + const chartRef = useRef(null); + const chartInstanceRef = useRef(null); + const option = useMemo(() => chartOption(document), [document]); + + useEffect(() => { + const element = chartRef.current; + if (!element) { + return; + } + const chart = echarts.init(element, undefined, { renderer: "canvas" }); + chartInstanceRef.current = chart; + chart.setOption(option, { notMerge: true }); + const observer = new ResizeObserver(() => chart.resize()); + observer.observe(element); + return () => { + observer.disconnect(); + chart.dispose(); + chartInstanceRef.current = null; + }; + }, []); + + useEffect(() => { + chartInstanceRef.current?.setOption(option, { notMerge: true }); + }, [option]); + + return ( +
    + ); +} + +function chartOption(document: DataViewDocument): EChartsCoreOption { + const style = getComputedStyle(documentElement()); + const ink = style.getPropertyValue("--color-ink").trim() || "#141413"; + const muted = style.getPropertyValue("--color-muted").trim() || "#6c6a64"; + const hairline = style.getPropertyValue("--color-hairline").trim() || "#e6dfd8"; + const primary = style.getPropertyValue("--color-primary").trim() || "#cc785c"; + const success = style.getPropertyValue("--color-success").trim() || "#5db872"; + const error = style.getPropertyValue("--color-error").trim() || "#c64545"; + const animation = !window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const base: EChartsCoreOption = { + animation, + aria: { enabled: true, decal: { show: true }, label: { description: `${document.title}. ${document.insight}` } }, + color: [primary, "#5577a8", success, "#9772b7", "#d09b3e", "#4c9494"], + grid: { left: 10, right: 18, top: 42, bottom: 8, containLabel: true }, + legend: { top: 4, textStyle: { color: muted, fontSize: 11 } }, + tooltip: { trigger: "axis", confine: true }, + textStyle: { color: ink, fontFamily: "Inter, system-ui, sans-serif" }, + }; + if (document.view.kind === "cartesian") { + const columns = new Map(document.dataset.columns.map((column) => [column.key, column])); + const usesRightAxis = document.view.series.some((series) => series.axis === "right"); + return { + ...base, + dataset: { source: document.dataset.rows.map((row) => row.values) }, + xAxis: { + type: "category", + axisLine: { lineStyle: { color: hairline } }, + axisLabel: { color: muted, hideOverlap: true }, + }, + yAxis: [ + valueAxis(columns, document.view.series.find((series) => series.axis !== "right")?.field, muted, hairline), + ...(usesRightAxis ? [valueAxis(columns, document.view.series.find((series) => series.axis === "right")?.field, muted, hairline)] : []), + ], + series: document.view.series.map((series) => ({ + type: series.mark === "bar" ? "bar" : "line", + name: columns.get(series.field)?.label ?? series.field, + encode: { x: document.view.kind === "cartesian" ? document.view.x : "", y: series.field }, + yAxisIndex: series.axis === "right" ? 1 : 0, + ...(series.mark === "area" ? { areaStyle: { opacity: 0.2 }, showSymbol: true } : {}), + ...(document.view.kind === "cartesian" && document.view.stack === "normal" ? { stack: "total" } : {}), + })), + }; + } + if (document.view.kind === "waterfall") { + const view = document.view; + const categoryColumn = document.dataset.columns.find((column) => column.key === view.category)!; + const valueColumn = document.dataset.columns.find((column) => column.key === view.value)!; + const waterfall = waterfallSeries(document); + return { + ...base, + xAxis: { + type: "category", + data: document.dataset.rows.map((row) => String(row.values[categoryColumn.key] ?? "")), + axisLine: { lineStyle: { color: hairline } }, + axisLabel: { color: muted, hideOverlap: true }, + }, + yAxis: valueAxis(new Map([[valueColumn.key, valueColumn]]), valueColumn.key, muted, hairline), + series: [ + { type: "bar", stack: "waterfall", silent: true, itemStyle: { color: "transparent" }, data: waterfall.base }, + { type: "bar", stack: "waterfall", name: "Increase", itemStyle: { color: success }, data: waterfall.positive }, + { type: "bar", stack: "waterfall", name: "Decrease", itemStyle: { color: error }, data: waterfall.negative }, + { type: "bar", stack: "waterfall", name: "Total", itemStyle: { color: primary }, data: waterfall.total }, + ], + }; + } + return base; +} + +function valueAxis(columns: Map, field: string | undefined, color: string, splitColor: string) { + const column = field ? columns.get(field) : undefined; + return { + type: "value" as const, + name: [column?.currency, column?.unit].filter(Boolean).join(" · "), + nameTextStyle: { color, fontSize: 10 }, + axisLabel: { color }, + splitLine: { lineStyle: { color: splitColor, type: "dashed" as const } }, + }; +} + +function waterfallSeries(document: DataViewDocument) { + if (document.view.kind !== "waterfall") { + return { base: [], positive: [], negative: [], total: [] }; + } + const base: Array = []; + const positive: Array = []; + const negative: Array = []; + const total: Array = []; + let running = 0; + for (const row of document.dataset.rows) { + const value = Number(row.values[document.view.value] ?? 0); + const isTotal = document.view.totalField ? row.values[document.view.totalField] === true : false; + if (isTotal) { + base.push(0); positive.push("-"); negative.push("-"); total.push(value); running = value; + } else if (value >= 0) { + base.push(running); positive.push(value); negative.push("-"); total.push("-"); running += value; + } else { + running += value; base.push(running); positive.push("-"); negative.push(-value); total.push("-"); + } + } + return { base, positive, negative, total }; +} + +function DataViewMetrics({ document, locale }: { document: DataViewDocument; locale?: string }) { + if (document.view.kind !== "metrics") { + return null; + } + const row = document.dataset.rows[document.dataset.rows.length - 1]; + const columns = new Map(document.dataset.columns.map((column) => [column.key, column])); + return ( +
    + {document.view.items.map((item) => { + const column = columns.get(item.field)!; + const comparison = item.comparisonField ? columns.get(item.comparisonField) : undefined; + return ( +
    +
    {column.label}
    +
    {formatDataViewCell(column, row.values[item.field], locale)}
    + {comparison ? {comparison.label}: {formatDataViewCell(comparison, row.values[comparison.key], locale)} : null} +
    + ); + })} +
    + ); +} + +function DataViewTable({ document, locale }: { document: DataViewDocument; locale?: string }) { + const fields = document.view.kind === "table" && document.view.fields?.length + ? document.view.fields + : document.dataset.columns.map((column) => column.key); + const columns = fields.map((field) => document.dataset.columns.find((column) => column.key === field)!).filter(Boolean); + const rows = sortedRows(document.dataset.rows, document); + return ( +
    + + {columns.map((column) => )} + + {rows.map((row) => ( + {columns.map((column) => )} + ))} + +
    {column.label}
    {formatDataViewCell(column, row.values[column.key], locale)}
    +
    + ); +} + +function sortedRows(rows: DataViewRow[], document: DataViewDocument): DataViewRow[] { + if (document.view.kind !== "table" || !document.view.defaultSort) { + return rows; + } + const { field, direction } = document.view.defaultSort; + return [...rows].sort((left, right) => { + const a = left.values[field]; + const b = right.values[field]; + const order = typeof a === "number" && typeof b === "number" + ? a - b + : String(a ?? "").localeCompare(String(b ?? "")); + return direction === "desc" ? -order : order; + }); +} + +function DataViewProvenance({ document }: { document: DataViewDocument }) { + const { t } = useTranslation("chat"); + return ( +
    +

    {t("dataView.provenance")}

    + {document.provenance.methodology ?

    {document.provenance.methodology}

    : null} + {document.provenance.sources.length ? ( +
      + {document.provenance.sources.map((source) => ( +
    • + {source.kind === "url" && source.uri ? {source.title} : {source.title}} + {source.locator ? {source.locator} : null} +
    • + ))} +
    + ) : null} + {document.provenance.caveats.length ? ( + <>

    {t("dataView.caveats")}

      {document.provenance.caveats.map((caveat, index) =>
    • {caveat}
    • )}
    + ) : null} +
    + ); +} + +function downloadCsv(document: DataViewDocument) { + const blob = new Blob([dataViewToCsv(document)], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = window.document.createElement("a"); + anchor.href = url; + anchor.download = `${safeFileName(document.title)}.csv`; + anchor.click(); + URL.revokeObjectURL(url); +} + +function safeFileName(value: string): string { + const safe = value.trim().replace(/[<>:"/\\|?*\u0000-\u001f]+/g, "-").replace(/\s+/g, " ").slice(0, 96); + return safe || "data-view"; +} + +function documentElement(): HTMLElement { + return window.document.documentElement; +} diff --git a/src/react-workbench/chat/ToolActivityItem.test.tsx b/src/react-workbench/chat/ToolActivityItem.test.tsx index 14558825..38fd1453 100644 --- a/src/react-workbench/chat/ToolActivityItem.test.tsx +++ b/src/react-workbench/chat/ToolActivityItem.test.tsx @@ -115,4 +115,19 @@ describe("ToolActivityItem", () => { expect(screen.getByText("error[E0308]: mismatched types")).toBeTruthy(); expect(screen.queryByText(/\{\"stderr\"/)).toBeNull(); }); + + it("labels a failed data view publication as failed", () => { + render(); + + expect(screen.getByText("Data view publication failed")).toBeTruthy(); + expect(screen.getByText("Failed")).toBeTruthy(); + }); }); diff --git a/src/react-workbench/chat/ToolActivityItem.tsx b/src/react-workbench/chat/ToolActivityItem.tsx index d60959db..49a66f83 100644 --- a/src/react-workbench/chat/ToolActivityItem.tsx +++ b/src/react-workbench/chat/ToolActivityItem.tsx @@ -230,6 +230,22 @@ function toolActivityDescriptor(toolCall: ToolCallState, status: ChatStepStatus, if (name === "update_plan") { return genericDescriptor(t("toolActivity.updatedPlan"), t("toolActivity.category.planning"), "plan", toolCall, fallbackSummary); } + if (name === "publish_data_view") { + const output = structuredResultPreview(toolCall) || fallbackSummary; + const title = status === "failed" + ? t("toolActivity.dataViewFailed") + : status === "cancelled" + ? t("toolActivity.dataViewCancelled") + : status === "running" || status === "pending" || status === "blocked" + ? t("toolActivity.preparingDataView") + : t("toolActivity.publishedDataView"); + return { + category: t("toolActivity.category.presentation"), + kind: "generic", + ...(output ? { output: { content: output, kind: "prose" } } : {}), + title, + }; + } if (name.startsWith("subagent.")) { const task = firstString(args.task, args.content); const title = name.endsWith("spawn") ? t("toolActivity.delegated") : name.endsWith("wait") ? t("toolActivity.waitedSubagents") : t("toolActivity.updatedSubagent"); diff --git a/src/react-workbench/i18n/resources/en.ts b/src/react-workbench/i18n/resources/en.ts index 69cf53f3..81d57bd3 100644 --- a/src/react-workbench/i18n/resources/en.ts +++ b/src/react-workbench/i18n/resources/en.ts @@ -494,6 +494,12 @@ export const en = { collapse: "Collapse", expand: "Expand", }, artifacts: { label: "Artifacts", preview: "Preview {{name}}" }, + dataView: { + actions: "Data view actions", invalid: "This data view could not be rendered safely.", views: "Data view display", + chart: "Chart", data: "Data", chartLabel: "{{title}}. {{insight}}", expand: "Expand {{title}}", expandAction: "Expand data view", + download: "Download {{title}} as CSV", downloadAction: "Download CSV", sourced: "Sourced", userProvided: "User-provided", unsourced: "Unsourced", + asOf: "As of {{date}}", dimensions: "{{rows}} rows · {{columns}} columns", provenance: "Sources and methodology", caveats: "Caveats", + }, reasoning: { label: "Reasoning", thinking: "Thinking", underSecond: "Thought for less than 1 second", seconds: "Thought for {{count}} seconds" }, context: { attachments: "Attachments", context: "Context" }, steps: { count: "{{count}} steps", title: "Execution details", label: "Agent steps", openDetails: "Open details for {{name}}", status: { active: "In progress", success: "Completed", waiting: "Waiting for confirmation", cancelled: "Cancelled", error: "Failed", pending: "Pending" } }, @@ -514,9 +520,9 @@ export const en = { toolActivity: { openDetails: "Open details for {{title}}", viewDetails: "View full tool details", toggleDetails: "Toggle details for {{title}}", truncated: "preview truncated", status: { completed: "Completed", running: "Running", waiting: "Waiting", failed: "Failed", cancelled: "Cancelled", pending: "Pending" }, - category: { terminal: "Terminal", fileRead: "File read", web: "Web", planning: "Planning", subagent: "Subagent", interaction: "Interaction", tool: "Tool" }, + category: { terminal: "Terminal", fileRead: "File read", web: "Web", planning: "Planning", presentation: "Data view", subagent: "Subagent", interaction: "Interaction", tool: "Tool" }, command: "command", workspaceFile: "workspace file", currentPage: "current page", usedTool: "Used a tool", - updatedPlan: "Updated execution plan", delegated: "Delegated a task", waitedSubagents: "Waited for subagents", updatedSubagent: "Updated a subagent", requestedInput: "Requested user input", + updatedPlan: "Updated execution plan", preparingDataView: "Preparing data view…", publishedDataView: "Published data view", dataViewFailed: "Data view publication failed", dataViewCancelled: "Data view publication cancelled", delegated: "Delegated a task", waitedSubagents: "Waited for subagents", updatedSubagent: "Updated a subagent", requestedInput: "Requested user input", commandFailed: "Command failed", commandCancelled: "Command cancelled", runningCommand: "Running {{command}}", waitingCommand: "Waiting to run {{command}}", ranCommand: "Ran {{command}}", inspectFailed: "Could not inspect {{target}}", inspecting: "Inspecting {{target}}", inspected: "Inspected {{target}}", webFailed: "Web action failed", opening: "Opening {{page}}", opened: "Opened {{page}}", reviewing: "Reviewing {{page}}", reviewed: "Reviewed {{page}}", scrolled: "Scrolled {{page}}", diff --git a/src/react-workbench/i18n/resources/zh.ts b/src/react-workbench/i18n/resources/zh.ts index b7f5556c..78e418b5 100644 --- a/src/react-workbench/i18n/resources/zh.ts +++ b/src/react-workbench/i18n/resources/zh.ts @@ -225,6 +225,12 @@ export const zh = { collapse: "收起", expand: "展开", }, artifacts: { label: "产物", preview: "预览 {{name}}" }, + dataView: { + actions: "数据视图操作", invalid: "此数据视图未通过安全校验,无法渲染。", views: "数据视图显示方式", + chart: "图表", data: "数据", chartLabel: "{{title}}。{{insight}}", expand: "展开 {{title}}", expandAction: "展开数据视图", + download: "将 {{title}} 下载为 CSV", downloadAction: "下载 CSV", sourced: "有来源", userProvided: "用户提供", unsourced: "无来源", + asOf: "截至 {{date}}", dimensions: "{{rows}} 行 · {{columns}} 列", provenance: "来源与方法", caveats: "注意事项", + }, reasoning: { label: "思考过程", thinking: "正在思考", underSecond: "思考了不到 1 秒", seconds: "思考了 {{count}} 秒" }, context: { attachments: "附件", context: "上下文" }, steps: { count: "{{count}} 个步骤", title: "执行详情", label: "Agent 步骤", openDetails: "打开 {{name}} 的详情", status: { active: "执行中", success: "已完成", waiting: "等待确认", cancelled: "已取消", error: "失败", pending: "待执行" } }, @@ -245,9 +251,9 @@ export const zh = { toolActivity: { openDetails: "打开 {{title}} 的详情", viewDetails: "查看完整工具详情", toggleDetails: "展开或收起 {{title}} 的详情", truncated: "预览已截断", status: { completed: "已完成", running: "执行中", waiting: "等待中", failed: "失败", cancelled: "已取消", pending: "待执行" }, - category: { terminal: "终端", fileRead: "读取文件", web: "网页", planning: "计划", subagent: "子 Agent", interaction: "交互", tool: "工具" }, + category: { terminal: "终端", fileRead: "读取文件", web: "网页", planning: "计划", presentation: "数据视图", subagent: "子 Agent", interaction: "交互", tool: "工具" }, command: "命令", workspaceFile: "工作区文件", currentPage: "当前页面", usedTool: "使用了工具", - updatedPlan: "更新了执行计划", delegated: "委派了任务", waitedSubagents: "等待子 Agent", updatedSubagent: "更新了子 Agent", requestedInput: "请求用户输入", + updatedPlan: "更新了执行计划", preparingDataView: "正在准备数据视图…", publishedDataView: "已发布数据视图", dataViewFailed: "数据视图发布失败", dataViewCancelled: "已取消发布数据视图", delegated: "委派了任务", waitedSubagents: "等待子 Agent", updatedSubagent: "更新了子 Agent", requestedInput: "请求用户输入", commandFailed: "命令执行失败", commandCancelled: "命令已取消", runningCommand: "正在执行 {{command}}", waitingCommand: "等待执行 {{command}}", ranCommand: "已执行 {{command}}", inspectFailed: "无法查看 {{target}}", inspecting: "正在查看 {{target}}", inspected: "已查看 {{target}}", webFailed: "网页操作失败", opening: "正在打开 {{page}}", opened: "已打开 {{page}}", reviewing: "正在查看 {{page}}", reviewed: "已查看 {{page}}", scrolled: "已滚动 {{page}}", diff --git a/src/react-workbench/styles/workbench.css b/src/react-workbench/styles/workbench.css index 8abf2499..a22709cc 100644 --- a/src/react-workbench/styles/workbench.css +++ b/src/react-workbench/styles/workbench.css @@ -1510,6 +1510,7 @@ button:disabled { .react-conversation-view { display: grid; + grid-auto-rows: max-content; align-content: start; gap: 16px; min-height: 0; @@ -6077,7 +6078,10 @@ button.tinyos-overlay-backdrop:focus-visible { right: 0; bottom: 0; z-index: 20; + display: grid; + grid-template-rows: auto minmax(0, 1fr); width: min(380px, 92vw); + overflow: hidden; border-left: 1px solid var(--color-hairline); background: #fff; padding: 16px; @@ -6100,7 +6104,7 @@ button.tinyos-overlay-backdrop:focus-visible { } } -.react-right-drawer > div, +.react-right-drawer__header, .react-command-palette > div { display: flex; align-items: center; @@ -6108,10 +6112,17 @@ button.tinyos-overlay-backdrop:focus-visible { gap: 12px; } +.react-right-drawer__content { + min-width: 0; + min-height: 0; + overflow: auto; + padding-top: 14px; +} + .react-tool-detail { display: grid; gap: 12px; - margin-top: 14px; + margin-top: 0; } .react-tool-detail section { @@ -7073,8 +7084,276 @@ button.tinyos-overlay-backdrop:focus-visible { font: inherit; } +.react-data-view { + align-self: start; + width: min(100%, 760px); + min-width: 0; + height: max-content; + margin: 6px 0 14px; + overflow: hidden; + border: 1px solid var(--color-hairline); + border-radius: 14px; + background: color-mix(in srgb, #fff 78%, var(--color-canvas)); + box-shadow: 0 9px 28px color-mix(in srgb, var(--color-ink) 6%, transparent); +} + +.react-data-view[data-expanded="true"] { + width: 100%; + margin: 0; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.react-data-view[data-state="invalid"] { + border-color: color-mix(in srgb, var(--color-error) 45%, var(--color-hairline)); +} + +.react-data-view__header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + padding: 16px 16px 10px; +} + +.react-data-view__icon { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 9px; + background: color-mix(in srgb, var(--color-primary) 13%, transparent); + color: var(--color-primary-active); +} + +.react-data-view__header h3, +.react-data-view__provenance h4 { + margin: 0; + color: var(--color-ink); +} + +.react-data-view__header h3 { + font-size: 14px; + font-weight: 720; + line-height: 1.35; +} + +.react-data-view__header p { + margin: 4px 0 0; + color: var(--color-muted); + font-size: 12px; + line-height: 1.55; +} + +.react-data-view__actions { + display: flex; + gap: 2px; +} + +.react-data-view__actions button { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + padding: 0; + color: var(--color-muted); +} + +.react-data-view__actions button:hover, +.react-data-view__actions button:focus-visible { + color: var(--color-ink); +} + +.react-data-view__tabs { + display: flex; + gap: 3px; + width: fit-content; + margin: 2px 16px 0 56px; + padding: 3px; + border-radius: 9px; + background: var(--color-surface-soft); +} + +.react-data-view__tabs button { + min-height: 28px; + padding: 0 10px; + border-radius: 7px; + color: var(--color-muted); + font-size: 11px; + font-weight: 650; +} + +.react-data-view__tabs button[aria-selected="true"] { + background: color-mix(in srgb, #fff 88%, var(--color-canvas)); + color: var(--color-ink); + box-shadow: 0 1px 3px color-mix(in srgb, var(--color-ink) 10%, transparent); +} + +.react-data-view__body { + min-width: 0; + padding: 8px 12px 2px; +} + +.react-data-view__chart { + width: 100%; + height: 280px; + min-height: 220px; +} + +.react-data-view[data-expanded="true"] .react-data-view__chart { + height: min(52vh, 480px); + min-height: 340px; +} + +.react-data-view__table-wrap { + max-height: 340px; + overflow: auto; + border: 1px solid var(--color-hairline); + border-radius: 10px; +} + +.react-data-view[data-expanded="true"] .react-data-view__table-wrap { + max-height: min(58vh, 620px); +} + +.react-data-view__table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.react-data-view__table th, +.react-data-view__table td { + padding: 8px 10px; + border-bottom: 1px solid var(--color-hairline); + text-align: left; +} + +.react-data-view__table th { + position: sticky; + z-index: 1; + top: 0; + background: var(--color-surface-soft); + color: var(--color-muted); + font-weight: 680; +} + +.react-data-view__table tr:last-child td { + border-bottom: 0; +} + +.react-data-view__table tbody tr:hover td { + background: color-mix(in srgb, var(--color-primary) 5%, transparent); +} + +.react-data-view__metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 8px; + margin: 0; +} + +.react-data-view__metrics > div { + min-width: 0; + padding: 12px; + border: 1px solid var(--color-hairline); + border-radius: 10px; + background: color-mix(in srgb, var(--color-surface-soft) 46%, transparent); +} + +.react-data-view__metrics dt, +.react-data-view__metrics small { + color: var(--color-muted); + font-size: 10px; +} + +.react-data-view__metrics dd { + margin: 5px 0 2px; + overflow-wrap: anywhere; + color: var(--color-ink); + font-size: 20px; + font-weight: 740; + font-variant-numeric: tabular-nums; +} + +.react-data-view__footer { + display: flex; + flex-wrap: wrap; + gap: 5px 12px; + padding: 10px 16px 13px; + color: var(--color-muted); + font-size: 10px; +} + +.react-data-view__footer span[data-status]::before { + display: inline-block; + width: 6px; + height: 6px; + margin-right: 5px; + border-radius: 999px; + background: var(--color-success); + content: ""; +} + +.react-data-view__footer span[data-status="user_provided"]::before { + background: var(--color-primary); +} + +.react-data-view__footer span[data-status="unsourced"] { + color: var(--color-warning); + font-weight: 680; +} + +.react-data-view__footer span[data-status="unsourced"]::before { + background: var(--color-warning); +} + +.react-data-view__provenance { + margin: 4px 16px 16px; + padding-top: 14px; + border-top: 1px solid var(--color-hairline); + color: var(--color-body); + font-size: 11px; + line-height: 1.5; +} + +.react-data-view__provenance h4 { + font-size: 11px; +} + +.react-data-view__provenance p, +.react-data-view__provenance ul { + margin: 6px 0 12px; +} + +.react-data-view__provenance li + li { + margin-top: 5px; +} + +.react-data-view__provenance li small { + display: block; + color: var(--color-muted); +} + +.react-data-view__provenance a { + color: var(--color-primary-active); +} + +.react-data-view__error { + margin: 0 16px 14px 56px; + color: var(--color-error); + font-size: 11px; + overflow-wrap: anywhere; +} + .react-artifact-detail { display: grid; + align-content: start; + min-width: 0; gap: 12px; } diff --git a/src/react-workbench/styles/workbench.test.ts b/src/react-workbench/styles/workbench.test.ts index c29924ae..0d6279ab 100644 --- a/src/react-workbench/styles/workbench.test.ts +++ b/src/react-workbench/styles/workbench.test.ts @@ -22,4 +22,17 @@ describe("workbench CSS interaction contracts", () => { expect(stylesheet).toContain("transition-duration: 140ms"); expect(stylesheet).not.toContain("transition-duration: 0ms !important"); }); + + test("keeps conversation rows intrinsic and scopes drawer header layout", () => { + const conversationRule = stylesheet.match(/\.react-conversation-view\s*\{([^}]+)\}/); + const drawerHeaderRule = stylesheet.match( + /\.react-right-drawer__header,\s*\.react-command-palette > div\s*\{([^}]+)\}/, + ); + const artifactDetailRule = stylesheet.match(/\.react-artifact-detail\s*\{([^}]+)\}/); + + expect(conversationRule?.[1]).toContain("grid-auto-rows: max-content"); + expect(drawerHeaderRule?.[1]).toContain("display: flex"); + expect(artifactDetailRule?.[1]).toContain("min-width: 0"); + expect(stylesheet).not.toContain(".react-right-drawer > div"); + }); }); From 696dfa7a9d4e09e1c7cec8869dcf3adaafdbdf3e Mon Sep 17 00:00:00 2001 From: Zhihao GU <1292162626@qq.com> Date: Mon, 10 Aug 2026 18:53:51 +0800 Subject: [PATCH 2/2] fix: address data view review findings --- src-tauri/src/agent/runtime/tests/tools.rs | 54 +++++++++++++++++++++ src-tauri/src/agent/runtime/tool_runtime.rs | 26 +++++----- src/app-core/chat/dataView.test.ts | 21 ++++++++ src/app-core/chat/dataView.ts | 5 +- 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/agent/runtime/tests/tools.rs b/src-tauri/src/agent/runtime/tests/tools.rs index 27654ec2..56b06149 100644 --- a/src-tauri/src/agent/runtime/tests/tools.rs +++ b/src-tauri/src/agent/runtime/tests/tools.rs @@ -2163,6 +2163,60 @@ fn publish_data_view_handles_multiple_calls_from_one_provider_response() { ); } +#[test] +fn mixed_data_view_error_and_other_tool_success_are_both_returned_to_the_model() { + let services = NativeAgentRuntimeServices::default() + .with_test_tool_registry_entries(test_registry_with_model_tools(&["workspace.read_file"])); + let result = run_native_agent_turn_with_config( + &services, + json!({ + "runtime": "rust", + "turnId": "turn-mixed-data-view", + "sessionId": "websocket:chat-mixed-data-view", + "maxIterations": 2, + "messages": [{ "role": "user", "content": "chart the workspace data" }] + }), + json!({ + "agents": { "defaults": { "provider": "fixture", "model": "fixture-model" } }, + "providers": { + "fixture": { + "responses": [ + { + "content": "", + "toolCalls": [ + { + "id": "call-data-view-mixed", + "name": "publish_data_view", + "argumentsJson": "{}" + }, + { + "id": "call-read-mixed", + "name": "workspace.read_file", + "argumentsJson": "{\"path\":\"README.md\"}", + "result": { "content": "README body" } + } + ] + }, + { "content": "handled mixed data view results" } + ] + } + } + }), + ) + .expect("mixed data view rejection and tool success should both reach the model"); + + assert_eq!(result["stopReason"], "final_response"); + assert_eq!(result["finalContent"], "handled mixed data view results"); + let completed = result["completedToolResults"] + .as_array() + .expect("completed tool results should be present"); + assert_eq!(completed.len(), 2); + assert_eq!(completed[0]["toolCallId"], "call-data-view-mixed"); + assert_eq!(completed[0]["status"], "error"); + assert_eq!(completed[1]["toolCallId"], "call-read-mixed"); + assert_eq!(completed[1]["status"], "ok"); +} + #[test] fn subagent_tools_share_manager_state_without_copying_child_transcript_to_parent() { let services = NativeAgentRuntimeServices::default().with_test_tool_registry_entries( diff --git a/src-tauri/src/agent/runtime/tool_runtime.rs b/src-tauri/src/agent/runtime/tool_runtime.rs index 04a795ce..0e092128 100644 --- a/src-tauri/src/agent/runtime/tool_runtime.rs +++ b/src-tauri/src/agent/runtime/tool_runtime.rs @@ -308,19 +308,19 @@ pub(super) async fn execute_tool_calls_for_iteration( .iter() .any(|tool_call| tool_call.name != PUBLISH_DATA_VIEW_METHOD) { - let tool_call = tool_calls - .iter() - .find(|tool_call| tool_call.name == PUBLISH_DATA_VIEW_METHOD) - .expect("publish_data_view presence was checked"); - return tool_error_result( - services, - context, - state, - iteration, - tool_call, - "publish_data_view cannot be mixed with other tools in its provider response" - .to_string(), - ); + let (data_view_calls, other_tool_calls): (Vec<_>, Vec<_>) = tool_calls + .into_iter() + .partition(|tool_call| tool_call.name == PUBLISH_DATA_VIEW_METHOD); + for tool_call in data_view_calls { + record_tool_failure( + context, + state, + iteration, + &tool_call, + "publish_data_view cannot be mixed with other tools in its provider response", + )?; + } + return execute_tool_batch(services, context, state, iteration, other_tool_calls).await; } return execute_publish_data_views(services, context, state, iteration, tool_calls); } diff --git a/src/app-core/chat/dataView.test.ts b/src/app-core/chat/dataView.test.ts index b282ba2f..466bc25a 100644 --- a/src/app-core/chat/dataView.test.ts +++ b/src/app-core/chat/dataView.test.ts @@ -47,6 +47,27 @@ describe("data view contract", () => { expect(formatDataViewCell(document.dataset.columns[2], 3.1, "en-US")).toBe("3.1%"); }); + test("neutralizes spreadsheet formulas in CSV text while preserving negative numbers", () => { + const input = validView(); + input.dataset.columns[0].label = "=Formula"; + input.dataset.rows = ["=1+1", "+SUM(A1:A2)", "-42", "@cmd", "\t=cmd", "\r=cmd"].map((period, index) => ({ + id: `row${index}`, + values: { period, revenue: -100 - index, growth: 0 }, + sourceIds: ["filing"], + })); + + const csv = dataViewToCsv(parseDataViewDocument(input)); + + expect(csv).toContain("\uFEFF'=Formula,Revenue,Growth\r\n"); + expect(csv).toContain("'=1+1,-100,0"); + expect(csv).toContain("'+SUM(A1:A2),-101,0"); + expect(csv).toContain("'-42,-102,0"); + expect(csv).toContain("'@cmd,-103,0"); + expect(csv).toContain("'\t=cmd,-104,0"); + expect(csv).toContain("\"'\r=cmd\",-105,0"); + expect(csv).not.toContain("'-100"); + }); + test("parses persisted artifacts whose absent optional fields were serialized as null", () => { const document = parseDataViewDocument({ schemaVersion: "tinybot.data_view.v1", diff --git a/src/app-core/chat/dataView.ts b/src/app-core/chat/dataView.ts index 98ce0e8d..4f956257 100644 --- a/src/app-core/chat/dataView.ts +++ b/src/app-core/chat/dataView.ts @@ -308,7 +308,10 @@ export function formatDataViewCell(column: DataViewColumn, value: DataViewCell | } function csvCell(value: DataViewCell | undefined): string { - const text = value === null || value === undefined ? "" : String(value); + let text = value === null || value === undefined ? "" : String(value); + if (typeof value === "string" && /^[=+\-@\t\r]/.test(text)) { + text = `'${text}`; + } return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; }