diff --git a/.Rbuildignore b/.Rbuildignore index f2e54628..7fcfcc14 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -5,6 +5,7 @@ ^README\.Rmd$ ^cran-comments\.md$ inst/resources +^inst/examples$ inst/slides/ inst/tiles/ plans/ diff --git a/DESCRIPTION b/DESCRIPTION index d93692f3..1135caa1 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -36,6 +36,7 @@ Imports: rlang (>= 1.2.0), roxygen2, S7, + shinychat (> 0.4.0), utils Suggests: bit64, @@ -44,6 +45,8 @@ Suggests: chromote, dbplyr, dplyr, + ggplot2, + gt, htmltools, odbc, otel (>= 0.2.0), @@ -55,7 +58,6 @@ Suggests: readr, rmarkdown, shiny (>= 1.11.1), - shinychat (> 0.4.0), shinytest2, testthat (>= 3.0.0), vitals, diff --git a/R/gt-output.R b/R/gt-output.R new file mode 100644 index 00000000..3e7c44f9 --- /dev/null +++ b/R/gt-output.R @@ -0,0 +1,22 @@ +is_gt_table <- function(x) { + inherits(x, "gt_tbl") +} + +recover_gt_table_data <- function(value, call = rlang::caller_env()) { + data <- value[["_data"]] + if (!is.data.frame(data)) { + cli::cli_abort( + "Can't recover data from the {.cls gt_tbl} returned by the measure.", + call = call + ) + } + as.data.frame(data) +} + +render_gt_table <- function(value) { + rendered <- htmltools::renderTags(htmltools::as.tags(value)) + list( + html = as.character(rendered$html), + dependencies = rendered$dependencies + ) +} diff --git a/R/measures.R b/R/measures.R index 0c9e8712..e0cc2904 100644 --- a/R/measures.R +++ b/R/measures.R @@ -118,6 +118,18 @@ expand_measures <- function(args, env = rlang::caller_env()) { #' body is ordinary R; its `arguments` schema tells the model what inputs it can #' supply. #' +#' Two return types receive special display handling: ggplots and [gt::gt()] +#' tables are shown directly to the user in the opened measure result. The model +#' is told that the plot or table has already been shown, so it can interpret the +#' result without repeating it. +#' +#' For full control over a result, `fn` can return an +#' [ellmer::ContentToolResult]. Its `value` is sent to the model and its +#' `extra$display` controls the shinychat display. When the display includes +#' HTML, Markdown, or text, the model is told that the result is already visible +#' to the user. An optional `extra$data` value is made available to `run_r` and +#' removed from the result before it is returned to ellmer. +#' #' @param name Measure name. #' @param description What the measure computes. #' @param fn Function that computes the measure. @@ -130,6 +142,27 @@ expand_measures <- function(args, env = rlang::caller_env()) { #' #' @return A measure object. #' +#' @examples +#' table <- data.frame(term = c("Headache", "Nausea"), count = c(7, 5)) +#' table_measure <- measure( +#' "adverse_events", +#' "Summarize adverse events.", +#' function() { +#' ellmer::ContentToolResult( +#' value = "Headache: 7; Nausea: 5", +#' extra = list( +#' display = shinychat::tool_result_display( +#' html = paste0( +#' "", +#' "
Headache7
Nausea5
" +#' ) +#' ), +#' data = table +#' ) +#' ) +#' } +#' ) +#' #' @seealso [semantic_layer()] to collect measures into a layer. #' #' @export diff --git a/R/plot-output.R b/R/plot-output.R new file mode 100644 index 00000000..1d17a64c --- /dev/null +++ b/R/plot-output.R @@ -0,0 +1,52 @@ +is_ggplot <- function(x) { + inherits(x, "ggplot") +} + +render_plot_image <- function(plot, alt) { + dims <- plot_dimensions() + path <- tempfile("commons-plot-", fileext = ".png") + on.exit(unlink(path), add = TRUE) + render_plot_png(plot, path, dims$width, dims$height) + model <- ellmer::content_image_file(path, resize = "none") + list( + model = model, + html = sprintf( + paste0( + "\"%s\"/" + ), + model@data, + html_escape(alt) + ) + ) +} + +plot_dimensions <- function() { + list(width = 768L, height = 512L) +} + +render_plot_png <- function( + plot, + path, + width, + height, + call = rlang::caller_env() +) { + if (requireNamespace("ragg", quietly = TRUE)) { + ragg::agg_png(path, width = width, height = height, scaling = 1.5) + } else { + grDevices::png(path, width = width, height = height) + } + tryCatch( + print(plot), + finally = grDevices::dev.off() + ) + + size <- file.size(path) + if (is.na(size) || size == 0) { + cli::cli_abort( + "Plot rendering did not produce a PNG image.", + call = call + ) + } +} diff --git a/R/run-r.R b/R/run-r.R index 49489371..b2c1044f 100644 --- a/R/run-r.R +++ b/R/run-r.R @@ -74,10 +74,7 @@ run_r_tool <- function(worker, handles, code, fn_sources = character()) { rlang::set_names(new_ids), function(id) get_handle(handles, id) ) - dims <- plot_dimensions( - getOption("commons.run_r_plot_aspect_ratio", "3:2"), - getOption("commons.run_r_plot_size", 768L) - ) + dims <- plot_dimensions() # callr rebinds a transferred function's environment to the worker's # global env, so the entry point must be namespace-qualified. worker$rs$call( @@ -173,6 +170,12 @@ run_r_value <- function(segments) { } flush() + if (any(vapply(segments, function(seg) seg$type == "plot", logical(1)))) { + out[[length(out) + 1L]] <- ellmer::ContentText( + visible_result_note("plot") + ) + } + if (length(out) == 0) { return("(The code ran but produced no output.)") } @@ -440,22 +443,6 @@ worker_await <- function( }) } -plot_dimensions <- function(ratio, longest_side) { - parts <- suppressWarnings( - as.numeric(strsplit(ratio, ":", fixed = TRUE)[[1]]) - ) - r <- if (length(parts) == 2 && all(!is.na(parts) & parts > 0)) { - parts[[1]] / parts[[2]] - } else { - 3 / 2 - } - if (r >= 1) { - list(width = as.integer(round(longest_side)), height = as.integer(round(longest_side / r))) - } else { - list(width = as.integer(round(longest_side * r)), height = as.integer(round(longest_side))) - } -} - # --- worker side ------------------------------------------------------------- # Everything below runs inside the callr worker process, whose closure # environment is reset to the global env, so these reference only base R, diff --git a/R/tools.R b/R/tools.R index fef43aaa..46caad55 100644 --- a/R/tools.R +++ b/R/tools.R @@ -182,7 +182,9 @@ tool_call_measure <- function(private) { "Run trusted calculations returned by", "search_pool. `arguments` is a JSON object using exactly the argument", "names from search_pool. Prefer a measure's own arguments when they can", - "answer the question directly." + "answer the question directly. Measure results may be displayed directly", + "to the user. If a result says it is already visible, do not reproduce", + "it in your reply; summarize or interpret the relevant results instead." ), arguments = list( name = ellmer::type_string( @@ -340,7 +342,19 @@ call_measure_tool <- function( source_ensure_all(sources[[source_name]]) } value <- do.call(td, c(args, injections[[name]])) + if (S7::S7_inherits(value, ellmer::ContentToolResult)) { + return(measure_content_tool_result(td, value, handles)) + } value <- collect_lazy_table(value) + if (is_ggplot(value)) { + advert <- register_handle(handles, value) + return(measure_plot_tool_result(td, args, value, advert)) + } + if (is_gt_table(value)) { + data <- recover_gt_table_data(value) + advert <- register_handle(handles, data) + return(measure_gt_table_tool_result(td, args, value, data, advert)) + } advert <- register_handle(handles, value) tool_result( paste(c(format_measure_value(value), advert), collapse = "\n\n"), @@ -352,6 +366,196 @@ call_measure_tool <- function( ) } +measure_content_tool_result <- function(td, result, handles) { + data <- result@extra$data + result@extra$data <- NULL + display <- result@extra$display + + if (is.null(result@error)) { + data <- collect_lazy_table(data) + advert <- register_handle(handles, data) + result@value <- append_handle_advert(result@value, advert) + if (measure_result_is_visible(display)) { + result@value <- prepend_model_note( + result@value, + visible_result_note("measure result") + ) + } + } + + title <- sprintf("Measure: %s", tool_title(td)) + icon <- maybe_icon("shield-check") + if (is.null(display)) { + display <- shinychat::tool_result_display(title = title, icon = icon) + } else if (is.list(display)) { + display$title <- display$title %||% title + display$icon <- display$icon %||% icon + } + result@extra$display <- display + result@extra$commons_tag <- "A" + result +} + +measure_result_is_visible <- function(display) { + is.list(display) && any(vapply( + display[c("html", "markdown", "text")], + Negate(is.null), + logical(1) + )) +} + +prepend_model_note <- function(value, note) { + note <- ellmer::ContentText(note) + if (S7::S7_inherits(value, ellmer::Content)) { + return(list(note, value)) + } + if ( + is.list(value) && + length(value) > 0 && + all(vapply(value, S7::S7_inherits, logical(1), ellmer::Content)) + ) { + return(c(list(note), value)) + } + paste(c(note@text, format_measure_value(value)), collapse = "\n\n") +} + +append_handle_advert <- function(value, advert) { + if (is.null(advert)) { + return(value) + } + note <- ellmer::ContentText(advert) + if (S7::S7_inherits(value, ellmer::Content)) { + return(list(value, note)) + } + if ( + is.list(value) && + length(value) > 0 && + all(vapply(value, S7::S7_inherits, logical(1), ellmer::Content)) + ) { + return(c(value, list(note))) + } + paste(c(format_measure_value(value), advert), collapse = "\n\n") +} + +measure_plot_tool_result <- function(td, args, value, advert) { + title <- tool_title(td) + rendered <- tryCatch( + render_plot_image(value, sprintf("Plot returned by %s", title)), + error = function(error) error + ) + if (inherits(rendered, "error")) { + return(measure_failure_result( + args, + advert, + title, + conditionMessage(rendered), + "a plot", + "commons-measure-plot-error" + )) + } + + model_value <- list( + ellmer::ContentText(visible_result_note("plot")), + rendered$model + ) + if (!is.null(advert)) { + model_value[[length(model_value) + 1L]] <- ellmer::ContentText(advert) + } + tool_result( + model_value, + title = sprintf("Measure: %s", html_escape(title)), + icon = maybe_icon("shield-check"), + html = measure_display_with_result_html( + args, + measure_result_html(rendered$html) + ), + tag = "A", + open = TRUE, + show_tag = FALSE + ) +} + +measure_failure_result <- function( + args, + advert, + title, + message, + result_type, + class, + model_content = NULL +) { + note <- sprintf( + "The measure returned %s, but it could not be displayed: %s", + result_type, + message + ) + tool_result( + paste(c(model_content, note, advert), collapse = "\n\n"), + title = sprintf("Measure: %s", html_escape(title)), + icon = maybe_icon("shield-check"), + html = measure_display_with_result_html( + args, + measure_result_html(html_escape(note), class) + ), + tag = "A", + open = TRUE, + show_tag = FALSE + ) +} + +measure_gt_table_tool_result <- function(td, args, value, data, advert) { + title <- tool_title(td) + rendered <- tryCatch( + render_gt_table(value), + error = function(error) error + ) + model_content <- df_to_markdown(data) + if (inherits(rendered, "error")) { + return(measure_failure_result( + args, + advert, + title, + conditionMessage(rendered), + "a gt table", + "commons-measure-gt-table-error", + model_content = model_content + )) + } + model_note <- c( + visible_result_note("gt table"), + if (!is.null(advert)) { + paste( + "For calculations, use `run_r` with the table handle below instead of", + "parsing values from the rendered table." + ) + } + ) + model_note <- paste(model_note, collapse = " ") + display_html <- measure_display_with_result_html( + args, + measure_result_html(rendered$html, "commons-measure-gt-table") + ) + if (length(rendered$dependencies) > 0) { + display_html <- htmltools::attachDependencies( + htmltools::HTML(display_html), + rendered$dependencies, + append = TRUE + ) + } + tool_result( + paste( + c(model_note, model_content, advert), + collapse = "\n\n" + ), + title = sprintf("Measure: %s", html_escape(title)), + icon = maybe_icon("shield-check"), + html = display_html, + tag = "A", + open = TRUE, + show_tag = FALSE + ) +} + search_context_tool <- function(context, query) { if (is.null(context)) { return("No context layer is configured for this agent.") @@ -532,6 +736,13 @@ tool_result <- function( ) } +visible_result_note <- function(type) { + paste( + sprintf("This %s is now visible to the user.", type), + "**Do not recreate or repeat it**." + ) +} + tag_label <- function(tag) { switch(tag, A = "Registered measure (A)", B = "SQL query (B)", tag) } @@ -589,17 +800,29 @@ measure_args_html <- function(args) { } measure_display_html <- function(args, value) { + measure_display_with_result_html( + args, + measure_result_html(format_measure_html(value)) + ) +} + +measure_display_with_result_html <- function(args, result_html) { sprintf( "
%s%s
", measure_args_html(args), - measure_result_html(value) + result_html ) } -measure_result_html <- function(value) { +measure_result_html <- function(content, class = NULL) { + class <- if (is.null(class)) "" else paste0(" ", class) sprintf( - "
Tool result
%s
", - format_measure_html(value) + paste0( + "
Tool result", + "
%s
" + ), + class, + content ) } diff --git a/inst/examples/plot-app/app.R b/inst/examples/plot-app/app.R new file mode 100644 index 00000000..5997de5c --- /dev/null +++ b/inst/examples/plot-app/app.R @@ -0,0 +1,46 @@ +# Run from the repository root with: +# shiny::runApp("inst/examples/plot-app") + +library(bslib) +library(ggplot2) +library(shiny) + +devtools::load_all("../../..") + +values <- data.frame( + group = c("A", "B", "C"), + value = c(8, 13, 5) +) + +measures <- semantic_layer( + measure( + "value_plot", + "Plot the value for each group.", + function() { + ggplot(values, aes(group, value, fill = group)) + + geom_col(show.legend = FALSE) + + labs(x = NULL, y = "Value") + }, + title = "Values by group" + ) +) + +ui <- page_fillable( + theme = bs_theme(version = 5), + commons_ui( + "chat", + greeting = "Try: Show the values by group." + ) +) + +server <- function(input, output, session) { + agent <- commons( + ellmer::chat_anthropic(), + data_sources = data_source(values = values), + semantic_layer = measures + ) + + commons_server("chat", agent) +} + +shinyApp(ui, server) diff --git a/inst/www/commons-chat/commons-chat.css b/inst/www/commons-chat/commons-chat.css index 55d022bc..1bd7119a 100644 --- a/inst/www/commons-chat/commons-chat.css +++ b/inst/www/commons-chat/commons-chat.css @@ -235,26 +235,47 @@ shiny-chat-container white-space: pre-wrap; } -.commons-measure-result-value table { +.commons-measure-result-value > table { border-collapse: collapse; font-size: 0.85rem; width: 100%; } -.commons-measure-result-value th, -.commons-measure-result-value td { +.commons-measure-result-value > table th, +.commons-measure-result-value > table td { border-bottom: 1px solid var(--bs-border-color, #dee2e6); padding: 0.3rem 0.5rem; } -.commons-measure-result-value th { +.commons-measure-result-value > table th { font-weight: 600; } -.commons-measure-result-value tbody tr:nth-child(even) { +.commons-measure-result-value > table tbody tr:nth-child(even) { background: var(--bs-tertiary-bg, #f3f4f6); } +.commons-measure-gt-table { + max-width: 100%; + overflow-x: auto; + white-space: normal; +} + +.commons-measure-gt-table-error, +.commons-measure-plot-error { + color: var(--bs-secondary-color, #6c757d); + white-space: normal; +} + +.commons-measure-plot { + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.5rem; + display: block; + height: auto; + max-width: min(100%, 34rem); + white-space: normal; +} + /* ---- run_r display ---------------------------------------------------- */ .commons-run-r-display { diff --git a/man/measure.Rd b/man/measure.Rd index 3630a4ff..b40f3ce1 100644 --- a/man/measure.Rd +++ b/man/measure.Rd @@ -28,6 +28,41 @@ A measure object. A measure is a governed calculation inside a \code{\link[=semantic_layer]{semantic_layer()}}. Its function body is ordinary R; its \code{arguments} schema tells the model what inputs it can supply. +} +\details{ +Two return types receive special display handling: ggplots and \code{\link[gt:gt]{gt::gt()}} +tables are shown directly to the user in the opened measure result. The model +is told that the plot or table has already been shown, so it can interpret the +result without repeating it. + +For full control over a result, \code{fn} can return an +\link[ellmer:ContentToolResult]{ellmer::ContentToolResult}. Its \code{value} is sent to the model and its +\code{extra$display} controls the shinychat display. When the display includes +HTML, Markdown, or text, the model is told that the result is already visible +to the user. An optional \code{extra$data} value is made available to \code{run_r} and +removed from the result before it is returned to ellmer. +} +\examples{ +table <- data.frame(term = c("Headache", "Nausea"), count = c(7, 5)) +table_measure <- measure( + "adverse_events", + "Summarize adverse events.", + function() { + ellmer::ContentToolResult( + value = "Headache: 7; Nausea: 5", + extra = list( + display = shinychat::tool_result_display( + html = paste0( + "", + "
Headache7
Nausea5
" + ) + ), + data = table + ) + ) + } +) + } \seealso{ \code{\link[=semantic_layer]{semantic_layer()}} to collect measures into a layer. diff --git a/tests/testthat/test-commons.R b/tests/testthat/test-commons.R index bb797bde..4c024465 100644 --- a/tests/testthat/test-commons.R +++ b/tests/testthat/test-commons.R @@ -58,6 +58,16 @@ test_that("run_r describes which results are visible to the user", { expect_match(description, "user cannot run code in this session") }) +test_that("call_measure describes how to handle visible results", { + agent <- test_agent( + semantic_layer = semantic_layer(count_measure_tool()) + ) + description <- tool_description(agent_tool(agent, "call_measure")) + + expect_match(description, "results may be displayed directly") + expect_match(description, "do not reproduce it in your reply") +}) + test_that("the system prompt includes tables and the date", { agent <- test_agent( semantic_layer = semantic_layer( diff --git a/tests/testthat/test-run-r.R b/tests/testthat/test-run-r.R index 8a3cfa50..f7aaf4e0 100644 --- a/tests/testthat/test-run-r.R +++ b/tests/testthat/test-run-r.R @@ -52,12 +52,21 @@ test_that("run_r returns plots as images and opens the display", { res <- sync_promise(run_r_tool(worker, store, "plot(r1$revenue)")) + images <- Filter( + \(x) S7::S7_inherits(x, ellmer::ContentImageInline), + res@value + ) + expect_length(images, 1) + notes <- Filter( + \(x) S7::S7_inherits(x, ellmer::ContentText), + res@value + ) expect_true(any(vapply( - res@value, - function(x) S7::S7_inherits(x, ellmer::ContentImageInline), + notes, + \(x) grepl("This plot is already visible to the user", x@text, fixed = TRUE), logical(1) ))) - expect_true(res@extra$display$open) + expect_identical(res@extra$display$open, TRUE) expect_match(res@extra$display$html, "data:image/png;base64,") expect_match(res@extra$display$html, "commons-run-r-details") }) diff --git a/tests/testthat/test-tools.R b/tests/testthat/test-tools.R index e21a43f6..971a212f 100644 --- a/tests/testthat/test-tools.R +++ b/tests/testthat/test-tools.R @@ -66,6 +66,235 @@ test_that("call_measure_tool registers scalar output as a handle", { expect_match(res@value, "Available to `run_r` as `r1`", fixed = TRUE) expect_equal(get_handle(store, "r1"), 6L) + expect_identical(res@extra$display$open, FALSE) +}) + +test_that("call_measure_tool supports custom ContentToolResult values", { + table <- data.frame(term = "Headache", count = 7) + display <- shinychat::tool_result_display( + html = htmltools::tags$table( + htmltools::tags$tr( + htmltools::tags$td("Headache"), + htmltools::tags$td("7") + ) + ), + open = TRUE + ) + registry <- list( + table = measure( + "table", + "Summarize adverse events.", + function() { + ellmer::ContentToolResult( + value = "Headache: 7", + extra = list( + display = display, + data = table, + custom = "preserved" + ) + ) + }, + title = "Adverse events & outcomes" + ) + ) + store <- new_handle_store() + + res <- call_measure_tool(registry, "table", "{}", handles = store) + + expect_match( + res@value, + "This measure result is already visible to the user", + fixed = TRUE + ) + expect_match(res@value, "Do not recreate or repeat it", fixed = TRUE) + expect_match(res@value, "Headache: 7", fixed = TRUE) + expect_match(res@value, "Available to `run_r` as `r1`", fixed = TRUE) + expect_identical(get_handle(store, "r1"), table) + expect_null(res@extra$data) + expect_identical(res@extra$custom, "preserved") + expect_identical(res@extra$display$html, display$html) + expect_identical(res@extra$display$open, TRUE) + expect_identical( + res@extra$display$title, + "Measure: Adverse events & outcomes" + ) + expect_equal(res@extra$commons_tag, "A") +}) + +test_that("call_measure_tool preserves image content in ContentToolResult", { + image <- ellmer::ContentImageInline("image/png", "YWJj") + data <- data.frame(x = 1) + registry <- list( + image = measure( + "image", + "Return an image.", + function() { + ellmer::ContentToolResult( + value = image, + extra = list(data = data) + ) + } + ) + ) + store <- new_handle_store() + + res <- call_measure_tool(registry, "image", "{}", handles = store) + + expect_length(res@value, 2) + expect_identical(res@value[[1]], image) + expect_s7_class(res@value[[2]], ellmer::ContentText) + expect_match( + res@value[[2]]@text, + "Available to `run_r` as `r1`", + fixed = TRUE + ) + expect_identical(get_handle(store, "r1"), data) + expect_null(res@extra$data) + expect_identical(res@extra$display$title, "Measure: image") +}) + +test_that("call_measure_tool preserves custom ContentToolResult errors", { + registry <- list( + error = measure( + "error", + "Return an authored error.", + function() { + ellmer::ContentToolResult( + error = "authored error", + extra = list(data = structure(list(), class = "tbl_sql")) + ) + } + ) + ) + store <- new_handle_store() + + res <- call_measure_tool(registry, "error", "{}", handles = store) + + expect_identical(res@error, "authored error") + expect_null(res@extra$data) + expect_length(handle_ids(store), 0) +}) + +test_that("call_measure_tool shows ggplot results to the model and user", { + skip_if_not_installed("ggplot2") + plot <- ggplot2::ggplot() + registry <- list( + plot = measure( + "plot", + "Plot values.", + function() plot, + title = 'A & "B"' + ) + ) + store <- new_handle_store() + + res <- call_measure_tool(registry, "plot", "{}", handles = store) + + images <- Filter( + \(x) S7::S7_inherits(x, ellmer::ContentImageInline), + res@value + ) + + expect_length(images, 1) + notes <- Filter( + \(x) S7::S7_inherits(x, ellmer::ContentText), + res@value + ) + expect_true(any(vapply( + notes, + \(x) grepl("This plot is already visible to the user", x@text, fixed = TRUE), + logical(1) + ))) + expect_match( + res@extra$display$html, + 'alt="Plot returned by A & "B""', + fixed = TRUE + ) + expect_s3_class(get_handle(store, "r1"), "ggplot") + expect_identical(res@extra$display$open, TRUE) +}) + +test_that("call_measure_tool shows gt tables to the model and user", { + skip_if_not_installed("gt") + table_data <- data.frame(term = "Headache", count = 7) + table <- gt::opt_interactive(gt::gt(table_data)) + registry <- list( + table = measure( + "table", + "Summarize adverse events.", + function() table + ) + ) + store <- new_handle_store() + + res <- call_measure_tool(registry, "table", "{}", handles = store) + + expect_match( + res@value, + "This gt table is already visible to the user", + fixed = TRUE + ) + expect_match( + res@value, + "For calculations, use `run_r` with the table handle below", + fixed = TRUE + ) + expect_match(res@value, "Headache", fixed = TRUE) + expect_no_match(res@value, "", fixed = TRUE) + expect_match(res@extra$display$html, "Headache", fixed = TRUE) + expect_gt(length(htmltools::findDependencies(res@extra$display$html)), 0) + expect_identical(get_handle(store, "r1"), table_data) + expect_identical(res@extra$display$open, TRUE) +}) + +test_that("call_measure_tool keeps recoverable table data when HTML conversion fails", { + skip_if_not_installed("gt") + table_data <- data.frame(term = "Headache", count = 7) + table <- gt::gt(table_data) + local_mocked_bindings( + render_gt_table = function(...) stop("HTML conversion broke") + ) + registry <- list( + table = measure( + "table", + "Summarize adverse events.", + function() table + ) + ) + store <- new_handle_store() + + res <- call_measure_tool(registry, "table", "{}", handles = store) + + expect_match(res@value, "Headache", fixed = TRUE) + expect_match( + res@extra$display$html, + "commons-measure-gt-table-error", + fixed = TRUE + ) + expect_identical(get_handle(store, "r1"), table_data) + expect_identical(res@extra$display$open, TRUE) +}) + +test_that("call_measure_tool keeps ggplot results when display rendering fails", { + skip_if_not_installed("ggplot2") + local_mocked_bindings( + render_plot_image = function(...) stop("graphics device broke") + ) + plot <- ggplot2::ggplot() + registry <- list( + plot = measure( + "plot", + "Plot values.", + function() plot + ) + ) + store <- new_handle_store() + + res <- call_measure_tool(registry, "plot", "{}", handles = store) + + expect_match(res@value, "could not be displayed") + expect_match(res@extra$display$html, "commons-measure-plot-error") + expect_s3_class(get_handle(store, "r1"), "ggplot") }) test_that("register_handle numbers handles in call order and caps rows", {