tests/testthat/test-console-ui.R

library(aisdk)

test_that("capture_r_execution captures output, messages, and warnings", {
  captured <- aisdk:::capture_r_execution({
    cat("hello\n")
    message("heads up")
    warning("careful")
  })

  expect_true(captured$ok)
  expect_equal(captured$output, "hello")
  expect_equal(captured$messages, "heads up")
  expect_equal(captured$warnings, "careful")

  formatted <- aisdk:::format_captured_execution(captured)
  expect_match(formatted, "hello")
  expect_match(formatted, "Message: heads up")
  expect_match(formatted, "Warning: careful")
})

test_that("Computer.execute_r_code captures warnings in returned output", {
  comp <- aisdk::Computer$new(working_dir = tempdir(), sandbox_mode = "permissive")
  result <- comp$execute_r_code("warning('careful'); 2 + 2")

  expect_false(result$error)
  expect_equal(result$warnings, "careful")
  rendered <- paste(result$output, collapse = "\n")
  expect_match(rendered, "4")
  expect_match(rendered, "Warning: careful")
})

test_that("compact tool labels hide raw json details", {
  label <- aisdk:::compact_tool_start_label(
    "execute_r_code",
    list(code = "packageVersion('ggtree')\nprint(installed.packages()[1, ])")
  )

  expect_match(label, "^Running R code:")
  expect_false(grepl("\\{|\\}|\\$installed", label))
})

test_that("handle_command toggles debug mode", {
  session <- aisdk::create_chat_session()

  on_result <- aisdk:::handle_command("/debug on", session, stream = TRUE, verbose = FALSE, show_thinking = FALSE)
  expect_true(on_result$verbose)
  expect_true(on_result$show_thinking)

  off_result <- aisdk:::handle_command("/debug off", session, stream = TRUE, verbose = TRUE, show_thinking = TRUE)
  expect_false(off_result$verbose)
  expect_false(off_result$show_thinking)
})

test_that("console input state initializes with user chat messages", {
  session <- aisdk::create_chat_session()
  session$append_message("user", "first question")
  session$append_message("assistant", "first answer")
  session$append_message("user", "second\nquestion")

  state <- aisdk:::console_create_input_state(session)

  expect_equal(state$history, c("first question", "second\nquestion"))
  expect_equal(state$history_index, 3L)
})

test_that("console input state uses a separate persisted chat history", {
  history_path <- tempfile("aisdk-console-history-")
  writeLines(c("previous chat", "another chat"), history_path)

  state <- aisdk:::console_create_input_state(history_path = history_path)
  expect_equal(state$history, c("previous chat", "another chat"))

  aisdk:::console_input_history_add(state, "new chat")
  expect_equal(readLines(history_path, warn = FALSE), c("previous chat", "another chat", "new chat"))
  expect_false(identical(basename(aisdk:::console_chat_history_path()), ".Rhistory"))
})

test_that("console input history recall navigates only chat input state", {
  state <- aisdk:::console_create_input_state()
  aisdk:::console_input_history_add(state, "first")
  aisdk:::console_input_history_add(state, "second")

  expect_equal(aisdk:::console_input_history_recall(state, "draft", "previous"), "second")
  expect_equal(aisdk:::console_input_history_recall(state, "", "previous"), "first")
  expect_equal(aisdk:::console_input_history_recall(state, "", "next"), "second")
  expect_equal(aisdk:::console_input_history_recall(state, "", "next"), "draft")
})

test_that("raw console backspace redraws wide-character input", {
  output <- capture.output(
    remaining <- aisdk:::console_delete_last_raw_input_char("  ", c("你", "好")),
    type = "output"
  )

  expect_equal(remaining, "你")
  expect_match(paste(output, collapse = "\n"), "\033\\[2K  你")
  expect_false(any(grepl("\b \b", output, fixed = TRUE)))
})

test_that("console readline returns a simple line immediately", {
  state <- aisdk:::console_create_input_state()
  lines <- c("alpha", "ignored")
  index <- 0L
  fake_readline <- function(prompt) {
    index <<- index + 1L
    lines[[index]]
  }

  input <- aisdk:::readline_multiline(state, readline_fn = fake_readline, quiet = TRUE)

  expect_equal(input, "alpha")
  expect_equal(index, 1L)
  expect_equal(state$history, "alpha")
})

test_that("console readline keeps slash commands single-line", {
  state <- aisdk:::console_create_input_state()
  lines <- c("/help", "ignored")
  index <- 0L
  fake_readline <- function(prompt) {
    index <<- index + 1L
    lines[[index]]
  }

  input <- aisdk:::readline_multiline(state, readline_fn = fake_readline, quiet = TRUE)

  expect_equal(input, "/help")
  expect_equal(index, 1L)
})

test_that("console readline auto-saves script-like paste without sending immediately", {
  state <- aisdk:::console_create_input_state()
  output_dir <- tempfile("console-paste-")
  clipboard_text <- paste(c(
    "### Create: Jianming Zeng",
    "library(Matrix)",
    "",
    "sce.all <- merge(x, y)"
  ), collapse = "\n")
  lines <- c(
    "### Create: Jianming Zeng",
    "library(Matrix)",
    "",
    "",
    "ignored"
  )
  index <- 0L
  fake_readline <- function(prompt) {
    index <<- index + 1L
    lines[[index]]
  }

  input <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )

  expect_equal(input, "")
  expect_s3_class(state$pending_paste, "aisdk_console_paste_ref")
  expect_true(file.exists(state$pending_paste$path))
  expect_equal(paste(readLines(state$pending_paste$path, warn = FALSE), collapse = "\n"), clipboard_text)
  expect_equal(state$pending_paste_drain, c("library(Matrix)", "", "sce.all <- merge(x, y)"))
  expect_s3_class(state$pending_paste_notice, "aisdk_console_paste_ref")
  expect_equal(index, 1L)
})

test_that("console readline handles RStudio-style multiline paste chunks", {
  state <- aisdk:::console_create_input_state()
  output_dir <- tempfile("console-paste-")
  clipboard_text <- paste(c(
    "###",
    "### Create: Jianming Zeng",
    "library(Matrix)",
    "folder <- file.path('matrix', pro)",
    "folder",
    "counts <- Read10X(folder)"
  ), collapse = "\n")
  lines <- c(
    paste(c(
      "###",
      "### Create: Jianming Zeng",
      "library(Matrix)",
      "folder <- file.path('matrix', pro)"
    ), collapse = "\n"),
    "folder",
    "counts <- Read10X(folder)",
    ""
  )
  index <- 0L
  fake_readline <- function(prompt) {
    index <<- index + 1L
    lines[[index]]
  }

  first <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )
  skipped_one <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )
  skipped_two <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )
  sent <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )

  expect_equal(first, "")
  expect_equal(skipped_one, "")
  expect_equal(skipped_two, "")
  expect_match(sent, "^\\[Pasted Content ")
  expect_null(state$pending_paste)
})

test_that("console readline skips queued paste lines before explicit send", {
  state <- aisdk:::console_create_input_state()
  output_dir <- tempfile("console-paste-")
  clipboard_text <- paste(c(
    "### Create: Jianming Zeng",
    "library(Matrix)",
    "",
    "sce.all <- merge(x, y)"
  ), collapse = "\n")
  lines <- c(
    "### Create: Jianming Zeng",
    "library(Matrix)",
    "",
    "sce.all <- merge(x, y)",
    "帮我解释这段代码"
  )
  index <- 0L
  prompts <- character(0)
  fake_readline <- function(prompt) {
    prompts <<- c(prompts, prompt)
    index <<- index + 1L
    lines[[index]]
  }

  first <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )
  skipped_one <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )
  skipped_two <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )
  skipped_three <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )
  sent <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )

  expect_equal(first, "")
  expect_equal(skipped_one, "")
  expect_equal(skipped_two, "")
  expect_equal(skipped_three, "")
  expect_match(sent, "^帮我解释这段代码\n\n\\[Pasted Content ")
  expect_null(state$pending_paste)
  expect_null(state$pending_paste_notice)
  expect_equal(prompts, c("  ", "", "", "", "  [paste pending] "))
})

test_that("console readline sends pending paste on explicit empty enter", {
  state <- aisdk:::console_create_input_state()
  output_dir <- tempfile("console-paste-")
  clipboard_text <- "### Create: Jianming Zeng\nlibrary(Matrix)"
  lines <- c("### Create: Jianming Zeng", "")
  index <- 0L
  fake_readline <- function(prompt) {
    index <<- index + 1L
    lines[[index]]
  }

  first <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )
  second <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )

  expect_equal(first, "")
  expect_match(second, "^\\[Pasted Content ")
  expect_null(state$pending_paste)
  expect_equal(state$history, second)
})

test_that("console readline combines pending paste with typed instructions", {
  state <- aisdk:::console_create_input_state()
  output_dir <- tempfile("console-paste-")
  clipboard_text <- "### Create: Jianming Zeng"
  lines <- c("### Create: Jianming Zeng", "帮我解释这段代码")
  index <- 0L
  fake_readline <- function(prompt) {
    index <<- index + 1L
    lines[[index]]
  }

  first <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )
  second <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )

  expect_equal(first, "")
  expect_match(second, "^帮我解释这段代码\n\n\\[Pasted Content ")
  expect_null(state$pending_paste)
})

test_that("console readline leaves pending paste intact for slash commands", {
  state <- aisdk:::console_create_input_state()
  output_dir <- tempfile("console-paste-")
  clipboard_text <- "### Create: Jianming Zeng"
  lines <- c("### Create: Jianming Zeng", "/help")
  index <- 0L
  fake_readline <- function(prompt) {
    index <<- index + 1L
    lines[[index]]
  }

  first <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )
  second <- aisdk:::readline_multiline(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    paste_output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )

  expect_equal(first, "")
  expect_equal(second, "/help")
  expect_s3_class(state$pending_paste, "aisdk_console_paste_ref")
})

test_that("console readline marks pending paste prompt without consuming slash commands", {
  state <- aisdk:::console_create_input_state()
  state$pending_paste <- aisdk:::console_create_paste_ref("/tmp/aisdk-paste.txt", 12L)
  prompts <- character(0)

  input <- aisdk:::readline_multiline(
    state,
    readline_fn = function(prompt) {
      prompts <<- c(prompts, prompt)
      "/history"
    },
    quiet = TRUE
  )

  expect_equal(input, "/history")
  expect_equal(prompts, "  [paste pending] ")
  expect_s3_class(state$pending_paste, "aisdk_console_paste_ref")
})

test_that("console paste file writer falls back to end marker when clipboard is unavailable", {
  state <- aisdk:::console_create_input_state()
  output_dir <- tempfile("console-paste-")
  lines <- c(
    "### Create: Jianming Zeng",
    "library(Matrix)",
    "",
    "/not-a-command-inside-paste",
    "/endpaste",
    "ignored"
  )
  index <- 0L
  fake_readline <- function(prompt) {
    index <<- index + 1L
    lines[[index]]
  }

  paste_ref <- aisdk:::console_read_paste_to_file(
    state,
    readline_fn = fake_readline,
    quiet = TRUE,
    output_dir = output_dir,
    clipboard_fn = function() NULL
  )

  expect_s3_class(paste_ref, "aisdk_console_paste_ref")
  expect_match(paste_ref$message, "^\\[Pasted Content ")
  expect_true(file.exists(paste_ref$path))
  expect_equal(paste(readLines(paste_ref$path, warn = FALSE), collapse = "\n"), paste(lines[1:4], collapse = "\n"))
  expect_equal(index, 5L)
})

test_that("console paste file writer uses clipboard when it matches the first line", {
  output_dir <- tempfile("console-paste-")
  clipboard_text <- "### Create: Jianming Zeng\nlibrary(Matrix)"

  paste_ref <- aisdk:::console_read_paste_to_file(
    readline_fn = function(prompt) stop("should not read more lines"),
    quiet = TRUE,
    initial_lines = "### Create: Jianming Zeng",
    output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )

  expect_equal(paste(readLines(paste_ref$path, warn = FALSE), collapse = "\n"), clipboard_text)
})

test_that("console bracketed paste event is saved without exposing content as input", {
  output_dir <- tempfile("console-paste-")
  paste_text <- "### Create: Jianming Zeng\r\nlibrary(Matrix)\r\n帮我检查"

  paste_ref <- aisdk:::console_save_paste_event(paste_text, output_dir = output_dir)

  expect_s3_class(paste_ref, "aisdk_console_paste_ref")
  expect_match(paste_ref$message, "^\\[Pasted Content ")
  expect_true(file.exists(paste_ref$path))
  expect_equal(paste(readLines(paste_ref$path, warn = FALSE), collapse = "\n"), "### Create: Jianming Zeng\nlibrary(Matrix)\n帮我检查")
})

test_that("console bracketed paste ignores short IME text", {
  output_dir <- tempfile("console-paste-")
  paste_ref <- aisdk:::console_save_paste_event("可以", output_dir = output_dir)

  expect_s3_class(paste_ref, "aisdk_console_paste_ref")
  expect_equal(paste_ref$path, "")
  expect_equal(paste_ref$message, "")
  expect_false(aisdk:::console_should_file_paste("吗?"))
  expect_false(aisdk:::console_should_file_paste("hello"))
})

test_that("console paste file writer stores queued complete clipboard lines", {
  state <- aisdk:::console_create_input_state()
  output_dir <- tempfile("console-paste-")
  clipboard_text <- "### Create: Jianming Zeng\nlibrary(Matrix)\nqsave(x)"

  paste_ref <- aisdk:::console_read_paste_to_file(
    state,
    readline_fn = function(prompt) stop("should not read queued lines immediately"),
    quiet = TRUE,
    initial_lines = "### Create: Jianming Zeng",
    output_dir = output_dir,
    clipboard_fn = function() clipboard_text
  )

  expect_equal(paste(readLines(paste_ref$path, warn = FALSE), collapse = "\n"), clipboard_text)
  expect_equal(state$pending_paste_drain, c("library(Matrix)", "qsave(x)"))
})

test_that("console auto-paste detection stays conservative", {
  expect_false(aisdk:::console_should_auto_paste("hello"))
  expect_false(aisdk:::console_should_auto_paste("可以给我个"))
  expect_false(aisdk:::console_should_auto_paste("吗?"))
  expect_false(aisdk:::console_should_auto_paste("/help"))
  expect_true(aisdk:::console_should_auto_paste("---"))
  expect_true(aisdk:::console_should_auto_paste("title: \"Anthropic研究员:用AI写代码\""))
  expect_true(aisdk:::console_should_auto_paste("### Create: Jianming Zeng"))
  expect_true(aisdk:::console_should_auto_paste("library(Seurat)"))
  expect_true(aisdk:::console_should_auto_paste("scRNAlist <- lapply(samples, function(pro) {"))
  expect_true(aisdk:::console_should_auto_paste("for (sample in samples) {"))
  expect_true(aisdk:::console_should_auto_paste("sce <- CreateSeuratObject(counts)"))
  expect_true(aisdk:::console_should_auto_paste("df |> dplyr::filter(group == 'A')"))
  expect_true(aisdk:::console_should_file_paste(paste(rep("plain text", 80), collapse = " ")))
})

test_that("console image command sends local image as multimodal message", {
  image_path <- tempfile(fileext = ".png")
  writeBin(as.raw(0:15), image_path)
  on.exit(unlink(image_path), add = TRUE)

  model <- MockModel$new(list(list(text = "ok", finish_reason = "stop")))
  model$capabilities <- list(vision_input = TRUE)
  session <- aisdk::create_chat_session(model = model)

  result <- aisdk:::handle_command(
    paste("/paste-image", image_path, "describe it"),
    session,
    stream = FALSE,
    verbose = FALSE,
    show_thinking = FALSE
  )

  expect_false(result$exit)
  user_message <- model$last_params$messages[[length(model$last_params$messages)]]
  expect_equal(user_message$content[[1]]$type, "input_text")
  expect_equal(user_message$content[[1]]$text, "describe it")
  expect_equal(user_message$content[[2]]$type, "input_image")
  expect_equal(user_message$content[[2]]$value, normalizePath(image_path, winslash = "/", mustWork = TRUE))

  history <- session$get_history()
  expect_equal(history[[1]]$role, "user")
  expect_equal(history[[1]]$content[[2]]$type, "input_image")
})

test_that("console image command stores clipboard image in cache with path context", {
  startup_dir <- tempfile("console-startup-")
  dir.create(startup_dir)
  on.exit(unlink(startup_dir, recursive = TRUE), add = TRUE)

  model <- MockModel$new(list(list(text = "ok", finish_reason = "stop")))
  model$capabilities <- list(vision_input = TRUE)
  session <- aisdk::create_chat_session(model = model)
  session$set_metadata("console_startup_dir", startup_dir)

  clipboard_image_fn <- function(output_dir) {
    expected_cache_dir <- file.path(
      normalizePath(startup_dir, winslash = "/", mustWork = TRUE),
      ".aisdk",
      "cache",
      "images"
    )
    expect_equal(
      output_dir,
      expected_cache_dir
    )
    dir.create(output_dir, recursive = TRUE, showWarnings = FALSE)
    path <- file.path(output_dir, "clipboard-image-test.png")
    writeBin(as.raw(0:15), path)
    normalizePath(path, winslash = "/", mustWork = TRUE)
  }

  result <- aisdk:::handle_command(
    "/paste-image",
    session,
    stream = FALSE,
    verbose = FALSE,
    show_thinking = FALSE,
    clipboard_image_fn = clipboard_image_fn
  )

  cached_path <- normalizePath(
    file.path(startup_dir, ".aisdk", "cache", "images", "clipboard-image-test.png"),
    winslash = "/",
    mustWork = TRUE
  )
  user_message <- model$last_params$messages[[length(model$last_params$messages)]]

  expect_false(result$exit)
  expect_equal(user_message$content[[1]]$type, "input_text")
  expect_match(user_message$content[[1]]$text, "Cached image file: clipboard-image-test.png", fixed = TRUE)
  expect_match(user_message$content[[1]]$text, paste0("Cached image path: ", cached_path), fixed = TRUE)
  expect_equal(user_message$content[[2]]$type, "input_image")
  expect_equal(user_message$content[[2]]$value, cached_path)
})

test_that("handle_command toggles inspect mode through app state", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  on_result <- aisdk:::handle_command(
    "/inspect on",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  expect_false(on_result$verbose)
  expect_false(on_result$show_thinking)
  expect_true(on_result$refresh_status)
  expect_equal(app_state$view_mode, "inspect")

  off_result <- aisdk:::handle_command(
    "/inspect off",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  expect_equal(app_state$view_mode, "clean")
  expect_true(off_result$refresh_status)
})

test_that("model command opens chooser when called without args", {
  session <- aisdk::create_chat_session(model = "openai:gpt-4o")
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  result <- aisdk:::handle_command(
    "/model",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state,
    model_prompt_fn = function(...) NULL
  )

  expect_false(result$refresh_status)
  expect_equal(session$get_model_id(), "openai:gpt-4o")
})

test_that("model command can switch via chooser result", {
  session <- aisdk::create_chat_session(model = "openai:gpt-4o")
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  result <- aisdk:::handle_command(
    "/model",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state,
    model_prompt_fn = function(...) "anthropic:claude-sonnet-4-20250514"
  )

  expect_true(result$refresh_status)
  expect_equal(session$get_model_id(), "anthropic:claude-sonnet-4-20250514")
  expect_equal(app_state$model_id, "anthropic:claude-sonnet-4-20250514")
})

test_that("model current reports the active model without switching", {
  session <- aisdk::create_chat_session(model = "openai:gpt-4o")
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  result <- aisdk:::handle_command(
    "/model current",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )

  expect_false(result$refresh_status)
  expect_equal(session$get_model_id(), "openai:gpt-4o")
})

test_that("skills command reloads the session skill registry", {
  skill_root <- tempfile("console-live-skills-")
  dir.create(file.path(skill_root, "live_skill"), recursive = TRUE)
  on.exit(unlink(skill_root, recursive = TRUE), add = TRUE)

  writeLines(c(
    "---",
    "name: live_skill",
    "description: Live skill",
    "---",
    "Original"
  ), file.path(skill_root, "live_skill", "SKILL.md"))

  agent <- create_agent(
    name = "SkillConsole",
    description = "Console with live skills",
    skills = skill_root
  )
  session <- create_chat_session(model = "mock:test", agent = agent)
  registry <- session$get_envir()$.skill_registry
  expect_equal(registry$get_skill("live_skill")$description, "Live skill")

  writeLines(c(
    "---",
    "name: live_skill",
    "description: Updated live skill",
    "---",
    "Updated"
  ), file.path(skill_root, "live_skill", "SKILL.md"))

  result <- aisdk:::handle_command(
    "/skills reload",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE
  )

  expect_true(result$refresh_status)
  expect_equal(registry$get_skill("live_skill")$description, "Updated live skill")
})

test_that("skills command defaults to list with no arguments", {
  agent <- create_console_agent(profile = "minimal")
  session <- create_chat_session(model = "mock:test", agent = agent)

  expect_error(
    result <- aisdk:::handle_command(
      "/skills",
      session,
      stream = TRUE,
      verbose = FALSE,
      show_thinking = FALSE
    ),
    NA
  )

  expect_false(result$refresh_status)
})

test_that("console_subcommand treats missing and blank args as default", {
  expect_equal(aisdk:::console_subcommand(character(0), default = "list"), "list")
  expect_equal(aisdk:::console_subcommand(NA_character_, default = "list"), "list")
  expect_equal(aisdk:::console_subcommand("", default = "list"), "list")
  expect_equal(aisdk:::console_subcommand("ROOTS", default = "list"), "roots")
})

test_that("model command updates context and thinking settings", {
  session <- aisdk::create_chat_session(model = "deepseek:deepseek-v4-flash")
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  context_result <- aisdk:::handle_command(
    "/model context 512k",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  expect_true(context_result$refresh_status)
  expect_equal(session$get_model_options()$context_window, 512000)

  aisdk:::handle_command("/model output 64k", session, stream = TRUE, verbose = FALSE, show_thinking = FALSE, app_state = app_state)
  aisdk:::handle_command("/model max-tokens 700", session, stream = TRUE, verbose = FALSE, show_thinking = FALSE, app_state = app_state)
  aisdk:::handle_command("/model thinking on", session, stream = TRUE, verbose = FALSE, show_thinking = FALSE, app_state = app_state)
  aisdk:::handle_command("/model effort high", session, stream = TRUE, verbose = FALSE, show_thinking = FALSE, app_state = app_state)
  aisdk:::handle_command("/model budget 2k", session, stream = TRUE, verbose = FALSE, show_thinking = FALSE, app_state = app_state)

  options <- session$get_model_options()
  expect_equal(options$max_output_tokens, 64000)
  expect_equal(options$call_options$max_tokens, 700)
  expect_true(options$call_options$thinking)
  expect_equal(options$call_options$reasoning_effort, "high")
  expect_equal(options$call_options$thinking_budget, 2000)

  line <- aisdk:::build_console_status_line(app_state)
  expect_match(line, "Ctx\\(est\\): 512.0k")
  expect_match(line, "Out: 64.0k")
  expect_match(line, "Think: on")
  expect_match(line, "Effort: high")
  expect_match(line, "Budget: 2.0k")
  expect_match(line, "Max: 700")

  clear_result <- aisdk:::handle_command(
    "/model thinking auto",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  expect_true(clear_result$refresh_status)
  expect_null(aisdk:::list_get_exact(session$get_model_options()$call_options, "thinking"))
})

test_that("persona commands update session persona state", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  set_result <- aisdk:::handle_command(
    "/persona set You are a brutal but precise reviewer.",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  expect_true(set_result$refresh_status)
  expect_equal(aisdk:::console_current_persona(session)$source, "manual")
  expect_equal(aisdk:::console_current_persona(session)$label, "custom")

  evolve_result <- aisdk:::handle_command(
    "/persona evolve Stay extra concise.",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  expect_true(evolve_result$refresh_status)
  expect_true(any(grepl("Stay extra concise.", aisdk:::console_current_persona(session)$notes, fixed = TRUE)))

  reset_result <- aisdk:::handle_command(
    "/persona default",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  expect_true(reset_result$refresh_status)
  expect_equal(aisdk:::console_current_persona(session)$source, "default")
})

test_that("console status line includes persona label", {
  session <- aisdk::create_chat_session(model = "openai:gpt-5-mini")
  aisdk:::console_set_manual_persona(session, "You are a skeptical reviewer.", label = "skeptic", locked = TRUE)
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  line <- aisdk:::build_console_status_line(app_state)

  expect_match(line, "Persona: skeptic:manual")
})

test_that("inspect commands open and close overlay state", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")
  aisdk:::console_app_start_turn(app_state, "Inspect latest turn")
  aisdk:::console_app_append_assistant_text(app_state, "Overlay me")
  aisdk:::console_app_finish_turn(app_state)

  turn_result <- aisdk:::handle_command(
    "/inspect turn",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  overlay <- aisdk:::console_app_get_active_overlay(app_state)
  expect_true(turn_result$refresh_status)
  expect_equal(overlay$type, "inspector")
  expect_match(overlay$title, "Inspector Overlay")
  expect_equal(app_state$focus_target, "overlay:inspector")

  close_result <- aisdk:::handle_command(
    "/inspect close",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  expect_true(close_result$refresh_status)
  expect_null(aisdk:::console_app_get_active_overlay(app_state))
  expect_equal(app_state$focus_target, "composer")
})

test_that("inspect next and prev navigate overlay tools", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")
  aisdk:::console_app_start_turn(app_state, "Inspect tools")
  aisdk:::console_app_append_assistant_text(app_state, "Two tools ran")
  aisdk:::console_app_record_tool_start(app_state, "execute_r_code", list(code = "1 + 1"))
  aisdk:::console_app_record_tool_result(app_state, "execute_r_code", "2")
  aisdk:::console_app_record_tool_start(app_state, "bash", list(command = "pwd"))
  aisdk:::console_app_record_tool_result(app_state, "bash", "/tmp")
  aisdk:::console_app_finish_turn(app_state)

  turn_result <- aisdk:::handle_command(
    "/inspect turn",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  expect_true(turn_result$refresh_status)
  expect_null(aisdk:::console_app_get_active_overlay(app_state)$payload$tool_index)

  next_result <- aisdk:::handle_command(
    "/inspect next",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  overlay <- aisdk:::console_app_get_active_overlay(app_state)
  expect_true(next_result$refresh_status)
  expect_equal(overlay$payload$tool_index, 1)
  expect_match(overlay$title, "Tool 1")

  next_result_2 <- aisdk:::handle_command(
    "/inspect next",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  overlay <- aisdk:::console_app_get_active_overlay(app_state)
  expect_true(next_result_2$refresh_status)
  expect_equal(overlay$payload$tool_index, 2)
  expect_match(overlay$title, "Tool 2")

  prev_result <- aisdk:::handle_command(
    "/inspect prev",
    session,
    stream = TRUE,
    verbose = FALSE,
    show_thinking = FALSE,
    app_state = app_state
  )
  overlay <- aisdk:::console_app_get_active_overlay(app_state)
  expect_true(prev_result$refresh_status)
  expect_equal(overlay$payload$tool_index, 1)
})

test_that("console status line reflects app state snapshot", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(
    session,
    sandbox_mode = "strict",
    stream_enabled = FALSE,
    local_execution_enabled = TRUE,
    view_mode = "inspect"
  )
  app_state$model_id <- "openai:gpt-5"
  app_state$tool_state <- "running"

  line <- aisdk:::build_console_status_line(app_state)

  expect_match(line, "Model: openai:gpt-5")
  expect_match(line, "Sandbox: strict")
  expect_match(line, "View: inspect")
  expect_match(line, "Stream: off")
  expect_match(line, "Local: on")
  expect_match(line, "Tools: running")
})

test_that("console status lines fold for narrow widths", {
  session <- aisdk::create_chat_session(model = "openai:gpt-5-mini")
  app_state <- aisdk:::create_console_app_state(
    session,
    sandbox_mode = "strict",
    stream_enabled = FALSE,
    local_execution_enabled = TRUE,
    view_mode = "inspect"
  )
  app_state$tool_state <- "running"

  wide_lines <- aisdk:::build_console_status_lines(app_state, width = 140)
  narrow_lines <- aisdk:::build_console_status_lines(app_state, width = 58)

  expect_length(wide_lines, 1)
  expect_true(length(narrow_lines) >= 2)
  expect_true(all(nchar(narrow_lines, type = "width") <= 58))
  expect_true(any(grepl("^Model:", narrow_lines)))
  expect_true(any(grepl("Tools: running", narrow_lines, fixed = TRUE)))
})

test_that("console frame groups status timeline and overlay sections", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")
  aisdk:::console_app_start_turn(app_state, "Inspect tools")
  aisdk:::console_app_append_assistant_text(app_state, "Two tools ran")
  aisdk:::console_app_record_tool_start(app_state, "execute_r_code", list(code = "1 + 1"))
  aisdk:::console_app_record_tool_result(app_state, "execute_r_code", "2")
  aisdk:::console_app_finish_turn(app_state)
  aisdk:::console_app_open_turn_overlay(app_state)

  frame <- aisdk:::build_console_frame(app_state)

  expect_equal(frame$status$type, "status")
  expect_equal(frame$status$tone, "muted")
  expect_equal(frame$timeline$type, "timeline")
  expect_equal(frame$timeline$tone, "subtle")
  expect_equal(frame$overlay$type, "overlay")
  expect_equal(frame$overlay$tone, "primary")
  expect_true(length(frame$status$lines) >= 2)
  expect_true(any(grepl("Model:", frame$status$lines, fixed = TRUE)))
  expect_true(any(grepl("execute_r_code", frame$timeline$lines, fixed = TRUE)))
  expect_true(any(grepl("Inspector Overlay", frame$overlay$lines, fixed = TRUE)))
  expect_true(isTRUE(frame$meta$has_overlay))
  expect_equal(frame$meta$focus_target, "overlay:inspector")
})

test_that("tool events are captured into the current turn timeline", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")
  aisdk:::console_app_start_turn(app_state, "Check package version")

  old_opts <- options(
    aisdk.console_app_state = app_state,
    aisdk.tool_log_mode = "compact"
  )
  on.exit(options(old_opts), add = TRUE)

  aisdk:::cli_tool_start("execute_r_code", list(code = "packageVersion('ggtree')"))
  aisdk:::cli_tool_result("execute_r_code", "4.0.1", success = TRUE)
  aisdk:::console_app_finish_turn(app_state)

  turn <- aisdk:::console_app_get_current_turn(app_state)
  lines <- aisdk:::format_console_tool_timeline(turn)

  expect_length(turn$tool_calls, 1)
  expect_equal(turn$tool_calls[[1]]$status, "done")
  expect_match(turn$tool_calls[[1]]$args_summary, "Running R code")
  expect_match(turn$tool_calls[[1]]$result_summary, "R code completed")
  expect_length(lines, 1)
  expect_match(lines[[1]], "execute_r_code")
  expect_match(lines[[1]], "\\[done\\]")
})

test_that("invalid tool arguments use a distinct compact result label", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")
  aisdk:::console_app_start_turn(app_state, "Run diagnostic code")

  old_opts <- options(
    aisdk.console_app_state = app_state,
    aisdk.tool_log_mode = "compact"
  )
  on.exit(options(old_opts), add = TRUE)

  raw_result <- list(error = TRUE, error_type = "invalid_tool_arguments")
  aisdk:::cli_tool_start("r_eval", list())
  aisdk:::cli_tool_result(
    "r_eval",
    "Error: invalid arguments for tool 'r_eval': Missing required argument `code`.",
    success = FALSE,
    raw_result = raw_result
  )
  aisdk:::console_app_finish_turn(app_state)

  turn <- aisdk:::console_app_get_current_turn(app_state)
  expect_match(turn$tool_calls[[1]]$result_summary, "r_eval call had invalid arguments", fixed = TRUE)
  expect_false(grepl("r_eval failed", turn$tool_calls[[1]]$result_summary, fixed = TRUE))
})

test_that("tool diagnostics are stored separately from tool summaries", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")
  aisdk:::console_app_start_turn(app_state, "Run diagnostic code")

  old_opts <- options(
    aisdk.console_app_state = app_state,
    aisdk.tool_log_mode = "compact"
  )
  on.exit(options(old_opts), add = TRUE)

  raw_result <- structure(
    "Result: 4\nMessage: heads up\nWarning: careful",
    aisdk_messages = "heads up",
    aisdk_warnings = "careful"
  )

  aisdk:::cli_tool_start("execute_r_code", list(code = "message('heads up'); warning('careful'); 2 + 2"))
  aisdk:::cli_tool_result("execute_r_code", raw_result, success = TRUE, raw_result = raw_result)
  aisdk:::console_app_finish_turn(app_state)

  turn <- aisdk:::console_app_get_current_turn(app_state)
  tool <- turn$tool_calls[[1]]
  lines <- aisdk:::format_console_tool_timeline(turn)

  expect_equal(tool$messages, "heads up")
  expect_equal(tool$warnings, "careful")
  expect_equal(turn$messages, "heads up")
  expect_equal(turn$warnings, "careful")
  expect_match(lines[[1]], "messages: 1")
  expect_match(lines[[1]], "warnings: 1")
})

test_that("streaming chunks accumulate into app state assistant text", {
  StreamingMockModel <- R6::R6Class(
    "StreamingMockModelForConsoleTests",
    inherit = aisdk:::LanguageModelV1,
    public = list(
      provider = "mock",
      model_id = "stream-mock",
      chunks = NULL,
      initialize = function(chunks) {
        self$chunks <- chunks
      },
      do_generate = function(params) {
        list(text = paste(self$chunks, collapse = ""), tool_calls = NULL, finish_reason = "stop")
      },
      do_stream = function(params, callback) {
        for (chunk in self$chunks) {
          callback(chunk, FALSE)
        }
        callback(NULL, TRUE)
        list(
          text = paste(self$chunks, collapse = ""),
          tool_calls = NULL,
          finish_reason = "stop",
          usage = list(total_tokens = length(self$chunks))
        )
      },
      format_tool_result = function(tool_call_id, tool_name, result) {
        list(role = "tool", tool_call_id = tool_call_id, name = tool_name, content = result)
      }
    )
  )

  model <- StreamingMockModel$new(c("hello ", "world"))
  session <- aisdk::create_chat_session(model = model)
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")
  aisdk:::console_app_start_turn(app_state, "Say hello")

  aisdk:::with_console_chat_display(app_state = app_state, code = {
    session$send_stream(
      "Say hello",
      callback = function(text, done) {
        if (!isTRUE(done)) {
          aisdk:::console_app_append_assistant_text(app_state, text)
        }
      }
    )
  })

  aisdk:::console_app_finish_turn(app_state)
  turn <- aisdk:::console_app_get_current_turn(app_state)

  expect_equal(turn$assistant_text, "hello world")
  expect_equal(turn$phase, "done")
  expect_equal(app_state$phase, "idle")
})

test_that("console typed stream events render assistant text deltas once", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")
  aisdk:::console_app_start_turn(app_state, "Say hello")

  aisdk:::console_handle_stream_event(
    list(type = "text_delta", text = "hello "),
    app_state = app_state,
    md_renderer = NULL
  )
  aisdk:::console_handle_stream_event(
    list(type = "text_delta", text = "world"),
    app_state = app_state,
    md_renderer = NULL
  )
  aisdk:::console_handle_stream_event(
    list(type = "final_text", text = "hello world", already_streamed = TRUE),
    app_state = app_state,
    md_renderer = NULL
  )

  turn <- aisdk:::console_app_get_current_turn(app_state)
  expect_equal(turn$assistant_text, "hello world")
})

test_that("console stream events render thinking without storing it as final text", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")
  aisdk:::console_app_start_turn(app_state, "Think first")

  aisdk:::console_handle_stream_event(
    list(type = "thinking_text", text = "<think>\nprivate reasoning\n</think>\n\n"),
    app_state = app_state,
    md_renderer = NULL
  )
  aisdk:::console_handle_stream_event(
    list(type = "final_text", text = "Visible answer."),
    app_state = app_state,
    md_renderer = NULL
  )

  turn <- aisdk:::console_app_get_current_turn(app_state)
  expect_equal(turn$assistant_text, "Visible answer.")
  expect_equal(turn$intermediate_text, "")
  expect_false(grepl("private reasoning", turn$assistant_text, fixed = TRUE))
})

test_that("console stream events filter text tool protocol markup", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")
  filter <- aisdk:::new_console_tool_call_markup_filter()
  aisdk:::console_app_start_turn(app_state, "Use a tool")

  chunks <- c(
    "我先检查。\n<tool_",
    "call>\n{\"name\":\"r_eval\",\"arguments\":{\"code\":\"pwd\"}}\n",
    "</tool_call>\n",
    "继续处理。\n"
  )
  for (chunk in chunks) {
    aisdk:::console_handle_stream_event(
      list(type = "text_delta", text = chunk),
      app_state = app_state,
      md_renderer = NULL,
      tool_markup_filter = filter
    )
  }
  aisdk:::console_handle_stream_event(
    list(type = "done", done = TRUE),
    app_state = app_state,
    md_renderer = NULL,
    tool_markup_filter = filter
  )

  turn <- aisdk:::console_app_get_current_turn(app_state)
  expect_match(turn$assistant_text, "我先检查", fixed = TRUE)
  expect_match(turn$assistant_text, "继续处理", fixed = TRUE)
  expect_false(grepl("<tool_call>", turn$assistant_text, fixed = TRUE))
  expect_false(grepl("r_eval", turn$assistant_text, fixed = TRUE))
})

test_that("console agent accepts native plain final text after tools", {
  echo_tool <- tool(
    name = "echo",
    description = "Echo a message",
    parameters = z_object(message = z_string("Message to echo")),
    execute = function(args) paste("Echo:", args$message)
  )

  model <- MockModel$new(list(
    list(
      text = "",
      tool_calls = list(list(
        id = "call_1",
        name = "echo",
        arguments = list(message = "console")
      )),
      finish_reason = "tool_calls",
      usage = list(total_tokens = 10)
    ),
    list(
      text = "Console protocol worked.",
      tool_calls = NULL,
      finish_reason = "stop",
      usage = list(total_tokens = 10)
    )
  ))
  model$capabilities <- list(native_tool_calling = TRUE)
  session <- aisdk::create_chat_session(model = model, tools = list(echo_tool))
  session$merge_metadata(list(console_agent_enabled = TRUE))
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  ok <- aisdk:::console_send_user_message(
    "Use the echo tool",
    session = session,
    stream = TRUE,
    app_state = app_state
  )

  turn <- aisdk:::console_app_get_last_turn(app_state)
  expect_true(ok)
  expect_match(turn$assistant_text, "Console protocol worked", fixed = TRUE)
  expect_equal(length(gregexpr("Console protocol worked", turn$assistant_text, fixed = TRUE)[[1]]), 1L)
  expect_false(grepl("<final_answer>", turn$assistant_text, fixed = TRUE))
  expect_length(model$responses, 0)
})

test_that("console agent hides text tool fallback markup while streaming", {
  echo_tool <- tool(
    name = "echo",
    description = "Echo a message",
    parameters = z_object(message = z_string("Message to echo")),
    execute = function(args) paste("Echo:", args$message)
  )

  model <- MockModel$new(list(
    list(
      text = paste0(
        "我先调用工具。\n",
        "<tool_call>\n",
        "{\"name\":\"echo\",\"arguments\":{\"message\":\"console\"}}\n",
        "</tool_call>\n"
      ),
      tool_calls = NULL,
      finish_reason = "stop",
      usage = list(total_tokens = 10)
    ),
    list(
      text = "工具结果已经处理完。",
      tool_calls = NULL,
      finish_reason = "stop",
      usage = list(total_tokens = 10)
    )
  ))
  model$capabilities <- list(native_tool_calling = FALSE)
  session <- aisdk::create_chat_session(model = model, tools = list(echo_tool))
  session$merge_metadata(list(console_agent_enabled = TRUE))
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  ok <- aisdk:::console_send_user_message(
    "Use the echo tool",
    session = session,
    stream = TRUE,
    app_state = app_state
  )

  turn <- aisdk:::console_app_get_last_turn(app_state)
  expect_true(ok)
  expect_match(turn$assistant_text, "工具结果已经处理完", fixed = TRUE)
  expect_false(grepl("<tool_call>", turn$assistant_text, fixed = TRUE))
  expect_false(grepl("echo", turn$assistant_text, fixed = TRUE))
})

test_that("console agent dedupes repeated final-looking intermediate text", {
  tool_seen <- character()
  echo_tool <- tool(
    name = "echo",
    description = "Echo a message",
    parameters = z_object(message = z_string("Message to echo")),
    execute = function(args) {
      tool_seen <<- c(tool_seen, args$message)
      paste("Echo:", args$message)
    }
  )

  report <- "FINAL REPORT\nThe package names are available."
  model <- MockModel$new(list(
    list(
      text = report,
      tool_calls = list(list(
        id = "call_1",
        name = "echo",
        arguments = list(message = "first")
      )),
      finish_reason = "tool_calls",
      usage = list(total_tokens = 10)
    ),
    list(
      text = report,
      tool_calls = list(list(
        id = "call_2",
        name = "echo",
        arguments = list(message = "second")
      )),
      finish_reason = "tool_calls",
      usage = list(total_tokens = 10)
    ),
    list(
      text = paste0("<final_answer>", report, "</final_answer>"),
      tool_calls = NULL,
      finish_reason = "stop",
      usage = list(total_tokens = 10)
    )
  ))
  model$capabilities <- list(native_tool_calling = TRUE)
  session <- aisdk::create_chat_session(model = model, tools = list(echo_tool))
  session$merge_metadata(list(console_agent_enabled = TRUE))
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  ok <- aisdk:::console_send_user_message(
    "Use the echo tool twice",
    session = session,
    stream = TRUE,
    app_state = app_state
  )

  turn <- aisdk:::console_app_get_last_turn(app_state)
  expect_true(ok)
  expect_equal(tool_seen, c("first", "second"))
  intermediate_matches <- gregexpr("FINAL REPORT", turn$intermediate_text, fixed = TRUE)[[1]]
  assistant_matches <- gregexpr("FINAL REPORT", turn$assistant_text, fixed = TRUE)[[1]]
  expect_equal(sum(intermediate_matches > 0), 1L)
  expect_equal(sum(assistant_matches > 0), 1L)
})

test_that("turn and tool inspector helpers expose structured details", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")
  aisdk:::console_app_start_turn(app_state, "Run diagnostic code")
  aisdk:::console_app_append_assistant_text(app_state, "Version check completed.")

  raw_result <- structure(
    "Result: 4\nMessage: heads up\nWarning: careful",
    aisdk_messages = "heads up",
    aisdk_warnings = "careful"
  )

  aisdk:::console_app_record_tool_start(app_state, "execute_r_code", list(code = "2 + 2"))
  aisdk:::console_app_record_tool_result(app_state, "execute_r_code", raw_result, raw_result = raw_result)
  aisdk:::console_app_finish_turn(app_state)

  turn <- aisdk:::console_app_get_last_turn(app_state)
  turn_lines <- aisdk:::format_console_turn_detail(turn)
  tool_lines <- aisdk:::format_console_tool_detail(turn, 1)

  expect_true(any(grepl("^Turn:", turn_lines)))
  expect_true(any(grepl("^Assistant: Version check completed\\.", turn_lines)))
  expect_true(any(grepl("^Messages: heads up$", turn_lines)))
  expect_true(any(grepl("^Warnings: careful$", turn_lines)))
  expect_true(any(grepl("^Tool: execute_r_code$", tool_lines)))
  expect_true(any(grepl("^Args raw:", tool_lines)))
  expect_true(any(grepl("^Result raw:", tool_lines)))
})

test_that("overlay helpers build boxed inspector content", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")
  aisdk:::console_app_start_turn(app_state, "Run diagnostic code")
  aisdk:::console_app_append_assistant_text(app_state, "Version check completed.")
  aisdk:::console_app_finish_turn(app_state)

  overlay <- aisdk:::console_app_open_turn_overlay(app_state)
  lines <- aisdk:::build_console_overlay_box(app_state, overlay)

  expect_equal(overlay$type, "inspector")
  expect_true(length(lines) >= 5)
  expect_true(any(grepl("Inspector Overlay", lines, fixed = TRUE)))
  expect_true(any(grepl("Close: /inspect close", lines, fixed = TRUE)))
})

test_that("console frame hides timeline outside inspect mode", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")
  aisdk:::console_app_start_turn(app_state, "Inspect tools")
  aisdk:::console_app_record_tool_start(app_state, "execute_r_code", list(code = "1 + 1"))
  aisdk:::console_app_record_tool_result(app_state, "execute_r_code", "2")
  aisdk:::console_app_finish_turn(app_state)

  frame <- aisdk:::build_console_frame(app_state)

  expect_length(frame$timeline$lines, 0)
  expect_false(isTRUE(frame$meta$has_overlay))
})

test_that("console_frame_section_changed detects unchanged status sections", {
  session <- aisdk::create_chat_session(model = "openai:gpt-5-mini")
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")
  frame1 <- aisdk:::build_console_frame(app_state)
  frame2 <- aisdk:::build_console_frame(app_state)

  expect_true(aisdk:::console_frame_section_changed(NULL, frame1, "status", force = FALSE))
  expect_false(aisdk:::console_frame_section_changed(frame1, frame2, "status", force = FALSE))
  expect_true(aisdk:::console_frame_section_changed(frame1, frame2, "status", force = TRUE))
})

test_that("inspector lines include navigation hints", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")
  aisdk:::console_app_start_turn(app_state, "Inspect tools")
  aisdk:::console_app_record_tool_start(app_state, "execute_r_code", list(code = "1 + 1"))
  aisdk:::console_app_record_tool_result(app_state, "execute_r_code", "2")
  aisdk:::console_app_finish_turn(app_state)

  turn <- aisdk:::console_app_get_last_turn(app_state)
  turn_lines <- aisdk:::build_console_inspector_lines(turn)
  tool_lines <- aisdk:::build_console_inspector_lines(turn, tool_index = 1)

  expect_true(any(grepl("/inspect next opens the first tool", turn_lines, fixed = TRUE)))
  expect_true(any(grepl("/inspect prev | /inspect next", tool_lines, fixed = TRUE)))
  expect_true(any(grepl("/inspect turn returns to the turn summary", tool_lines, fixed = TRUE)))
})

test_that("with_console_chat_display derives visibility from app state", {
  session <- aisdk::create_chat_session()
  app_state <- aisdk:::create_console_app_state(session, view_mode = "inspect")

  aisdk:::with_console_chat_display(app_state = app_state, code = {
    expect_equal(getOption("aisdk.tool_log_mode"), "compact")
    expect_false(getOption("aisdk.show_thinking"))
    expect_identical(getOption("aisdk.console_app_state"), app_state)
  })

  aisdk:::console_app_set_view_mode(app_state, "debug")

  aisdk:::with_console_chat_display(app_state = app_state, code = {
    expect_equal(getOption("aisdk.tool_log_mode"), "detailed")
    expect_true(getOption("aisdk.show_thinking"))
  })
})

test_that("console_send_user_message cancels turn and restores history on interrupt", {
  interrupt_model <- MockModel$new(list(function(params) {
    stop(structure(list(message = "interrupt"), class = c("interrupt", "condition")))
  }))
  session <- aisdk::create_chat_session(model = interrupt_model)
  session$append_message("user", "existing")
  session$append_message("assistant", "history")
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  ok <- aisdk:::console_send_user_message(
    "please stop",
    session = session,
    stream = TRUE,
    app_state = app_state
  )

  expect_false(ok)
  expect_equal(vapply(session$get_history(), `[[`, character(1), "content"), c("existing", "history"))
  turn <- aisdk:::console_app_get_current_turn(app_state)
  expect_equal(turn$phase, "cancelled")
  expect_equal(app_state$phase, "cancelled")
  expect_equal(app_state$tool_state, "idle")
})

test_that("console_send_user_message restores history on send errors", {
  error_model <- MockModel$new(list(function(params) {
    stop("boom")
  }))
  session <- aisdk::create_chat_session(model = error_model)
  session$append_message("user", "existing")
  app_state <- aisdk:::create_console_app_state(session, view_mode = "clean")

  ok <- aisdk:::console_send_user_message(
    "will fail",
    session = session,
    stream = FALSE,
    app_state = app_state
  )

  expect_false(ok)
  expect_equal(length(session$get_history()), 1L)
  expect_equal(session$get_history()[[1]]$content, "existing")
  expect_equal(aisdk:::console_app_get_current_turn(app_state)$phase, "error")
})

Try the aisdk package in your browser

Any scripts or data that you put into this service are public.

aisdk documentation built on May 29, 2026, 9:07 a.m.