feat(acp): introduce threads (#8344)

Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
Bradley Axen
2026-04-08 22:14:28 -07:00
committed by GitHub
parent 331d1e2efb
commit 9d7de43eec
23 changed files with 2207 additions and 880 deletions
+44 -1
View File
@@ -3,7 +3,9 @@ mod name_builder;
mod registry;
pub use model::{CanonicalModel, Limit, Modalities, Modality, Pricing};
pub use name_builder::{canonical_name, map_to_canonical_model, strip_version_suffix};
pub use name_builder::{
canonical_name, map_provider_name, map_to_canonical_model, strip_version_suffix,
};
pub use registry::CanonicalModelRegistry;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -21,6 +23,47 @@ impl ModelMapping {
}
}
/// Return recommended model names for a provider using only the bundled canonical registry.
///
/// This avoids network calls by looking up all known models for the provider,
/// filtering to text-input + tool-calling models, and sorting by release date.
/// The returned names are the canonical short names (e.g. "claude-3.5-sonnet").
///
/// TODO: This trades speed for correctness — the canonical registry may not perfectly
/// match what the provider API returns (new models not yet in the registry, deprecated
/// models still listed, or locally-installed models for providers like Ollama). Consider
/// whether to reconcile with a live API call in the background.
pub fn recommended_models_from_registry(provider: &str) -> Vec<String> {
let registry = match CanonicalModelRegistry::bundled() {
Ok(r) => r,
Err(_) => return vec![],
};
let registry_provider = map_provider_name(provider);
let all = registry.get_all_models_for_provider(registry_provider);
let mut models_with_dates: Vec<(String, Option<String>)> = all
.iter()
.filter(|m| m.modalities.input.contains(&Modality::Text) && m.tool_call)
.filter_map(|m| {
let (_, name) = m.id.split_once('/')?;
Some((name.to_string(), m.release_date.clone()))
})
.collect();
models_with_dates.sort_by(|a, b| match (&a.1, &b.1) {
(Some(date_a), Some(date_b)) => date_b.cmp(date_a),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.0.cmp(&b.0),
});
models_with_dates
.into_iter()
.map(|(name, _)| name)
.collect()
}
pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option<CanonicalModel> {
let registry = CanonicalModelRegistry::bundled().ok()?;
@@ -39,7 +39,7 @@ fn is_meta_provider(provider: &str) -> bool {
matches!(provider, "databricks" | "tetrate" | "bedrock" | "azure")
}
fn map_provider_name(provider: &str) -> &str {
pub fn map_provider_name(provider: &str) -> &str {
match provider {
// Goose provider names that differ from models.dev names
"xai" => "x-ai",
+2
View File
@@ -3,6 +3,7 @@ mod diagnostics;
pub mod extension_data;
mod legacy;
pub mod session_manager;
pub mod thread_manager;
pub use diagnostics::{
config_path, generate_diagnostics, get_system_info, latest_llm_log_path,
@@ -12,3 +13,4 @@ pub use extension_data::{EnabledExtensionsState, ExtensionData, ExtensionState,
pub use session_manager::{
Session, SessionInsights, SessionManager, SessionType, SessionUpdateBuilder,
};
pub use thread_manager::{Thread, ThreadManager, ThreadMetadata};
+134 -8
View File
@@ -19,7 +19,7 @@ use std::sync::{Arc, LazyLock};
use tracing::{info, warn};
use utoipa::ToSchema;
pub const CURRENT_SCHEMA_VERSION: i32 = 9;
pub const CURRENT_SCHEMA_VERSION: i32 = 10;
pub const SESSIONS_FOLDER: &str = "sessions";
pub const DB_NAME: &str = "sessions.db";
@@ -81,6 +81,8 @@ pub struct Session {
pub model_config: Option<ModelConfig>,
#[serde(default)]
pub goose_mode: GooseMode,
#[serde(default)]
pub thread_id: Option<String>,
}
pub struct SessionUpdateBuilder<'a> {
@@ -103,6 +105,7 @@ pub struct SessionUpdateBuilder<'a> {
provider_name: Option<Option<String>>,
model_config: Option<Option<ModelConfig>>,
goose_mode: Option<GooseMode>,
thread_id: Option<Option<String>>,
}
#[derive(Serialize, ToSchema, Debug)]
@@ -134,6 +137,7 @@ impl<'a> SessionUpdateBuilder<'a> {
provider_name: None,
model_config: None,
goose_mode: None,
thread_id: None,
}
}
@@ -241,6 +245,11 @@ impl<'a> SessionUpdateBuilder<'a> {
self.goose_mode = Some(mode);
self
}
pub fn thread_id(mut self, thread_id: Option<String>) -> Self {
self.thread_id = Some(thread_id);
self
}
}
pub struct SessionManager {
@@ -361,7 +370,22 @@ impl SessionManager {
if user_message_count <= MSG_COUNT_FOR_SESSION_NAME_GENERATION {
let name = provider.generate_session_name(id, &conversation).await?;
self.update(id).system_generated_name(name).apply().await
self.update(id)
.system_generated_name(name.clone())
.apply()
.await?;
// Also update the thread name so ACP clients see it via session/list.
if let Some(ref thread_id) = session.thread_id {
let thread_mgr = super::thread_manager::ThreadManager::new(self.storage.clone());
let thread = thread_mgr.get_thread(thread_id).await?;
if !thread.user_set_name {
thread_mgr
.update_thread(thread_id, Some(name), Some(false), None)
.await?;
}
}
Ok(())
} else {
Ok(())
}
@@ -407,7 +431,7 @@ pub struct SessionStorage {
session_dir: PathBuf,
}
fn role_to_string(role: &Role) -> &'static str {
pub(crate) fn role_to_string(role: &Role) -> &'static str {
match role {
Role::User => "user",
Role::Assistant => "assistant",
@@ -439,6 +463,7 @@ impl Default for Session {
provider_name: None,
model_config: None,
goose_mode: GooseMode::default(),
thread_id: None,
}
}
}
@@ -508,6 +533,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session {
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or_default(),
thread_id: row.try_get("thread_id").ok().flatten(),
})
}
}
@@ -537,7 +563,7 @@ impl SessionStorage {
}
}
async fn pool(&self) -> Result<&Pool<Sqlite>> {
pub(crate) async fn pool(&self) -> Result<&Pool<Sqlite>> {
self.initialized
.get_or_try_init(|| async {
let schema_exists = sqlx::query_scalar::<_, bool>(
@@ -607,7 +633,8 @@ impl SessionStorage {
user_recipe_values_json TEXT,
provider_name TEXT,
model_config_json TEXT,
goose_mode TEXT NOT NULL DEFAULT 'auto'
goose_mode TEXT NOT NULL DEFAULT 'auto',
thread_id TEXT
)
"#,
)
@@ -647,6 +674,48 @@ impl SessionStorage {
sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)")
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_sessions_thread ON sessions(thread_id)")
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT 'New Chat',
user_set_name BOOLEAN DEFAULT FALSE,
working_dir TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_at TIMESTAMP,
metadata_json TEXT DEFAULT '{}'
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS thread_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT NOT NULL REFERENCES threads(id),
session_id TEXT,
message_id TEXT,
role TEXT NOT NULL,
content_json TEXT NOT NULL,
created_timestamp INTEGER NOT NULL,
metadata_json TEXT DEFAULT '{}'
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_thread_messages_thread ON thread_messages(thread_id)",
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_thread_messages_message_id ON thread_messages(message_id)")
.execute(pool)
.await?;
Ok(())
}
@@ -938,6 +1007,59 @@ impl SessionStorage {
.execute(&mut **tx)
.await?;
}
10 => {
// Check if thread_id column already exists (e.g. fresh schema)
let has_thread_id = sqlx::query_scalar::<_, i32>(
"SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name = 'thread_id'",
)
.fetch_one(&mut **tx)
.await?
> 0;
if !has_thread_id {
sqlx::query("ALTER TABLE sessions ADD COLUMN thread_id TEXT")
.execute(&mut **tx)
.await?;
}
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_sessions_thread ON sessions(thread_id)",
)
.execute(&mut **tx)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT 'New Chat',
user_set_name BOOLEAN DEFAULT FALSE,
working_dir TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_at TIMESTAMP,
metadata_json TEXT DEFAULT '{}'
)",
)
.execute(&mut **tx)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS thread_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT NOT NULL REFERENCES threads(id),
session_id TEXT,
message_id TEXT,
role TEXT NOT NULL,
content_json TEXT NOT NULL,
created_timestamp INTEGER NOT NULL,
metadata_json TEXT DEFAULT '{}'
)",
)
.execute(&mut **tx)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_thread_messages_thread ON thread_messages(thread_id)")
.execute(&mut **tx)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_thread_messages_message_id ON thread_messages(message_id)")
.execute(&mut **tx)
.await?;
}
_ => {
anyhow::bail!("Unknown migration version: {}", version);
}
@@ -999,7 +1121,7 @@ impl SessionStorage {
total_tokens, input_tokens, output_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json, user_recipe_values_json,
provider_name, model_config_json, goose_mode
provider_name, model_config_json, goose_mode, thread_id
FROM sessions
WHERE id = ?
"#,
@@ -1063,6 +1185,7 @@ impl SessionStorage {
add_update!(builder.provider_name, "provider_name");
add_update!(builder.model_config, "model_config_json");
add_update!(builder.goose_mode, "goose_mode");
add_update!(builder.thread_id, "thread_id");
if updates.is_empty() {
return Ok(());
@@ -1131,6 +1254,9 @@ impl SessionStorage {
if let Some(goose_mode) = builder.goose_mode {
q = q.bind(goose_mode.to_string());
}
if let Some(thread_id) = builder.thread_id {
q = q.bind(thread_id);
}
let pool = self.pool().await?;
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
@@ -1282,10 +1408,10 @@ impl SessionStorage {
s.total_tokens, s.input_tokens, s.output_tokens,
s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens,
s.schedule_id, s.recipe_json, s.user_recipe_values_json,
s.provider_name, s.model_config_json, s.goose_mode,
s.provider_name, s.model_config_json, s.goose_mode, s.thread_id,
COUNT(m.id) as message_count
FROM sessions s
INNER JOIN messages m ON s.id = m.session_id
LEFT JOIN messages m ON s.id = m.session_id
{}
GROUP BY s.id
ORDER BY s.updated_at DESC
+433
View File
@@ -0,0 +1,433 @@
use super::session_manager::{role_to_string, SessionStorage};
use crate::conversation::message::Message;
use anyhow::Result;
use chrono::{DateTime, Utc};
use rmcp::model::Role;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread {
pub id: String,
pub name: String,
pub user_set_name: bool,
pub working_dir: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub archived_at: Option<DateTime<Utc>>,
pub metadata: ThreadMetadata,
#[serde(default)]
pub current_session_id: Option<String>,
#[serde(default)]
pub message_count: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ThreadMetadata {
#[serde(default)]
pub persona_id: Option<String>,
#[serde(default)]
pub project_id: Option<String>,
#[serde(default)]
pub provider_id: Option<String>,
#[serde(default)]
pub model_name: Option<String>,
#[serde(default)]
pub mode: Option<String>,
#[serde(flatten)]
pub extra: std::collections::HashMap<String, serde_json::Value>,
}
pub struct ThreadManager {
storage: Arc<SessionStorage>,
}
const THREAD_SELECT: &str = "\
SELECT t.id, t.name, t.user_set_name, t.working_dir, t.created_at, t.updated_at, \
t.archived_at, t.metadata_json, \
(SELECT s.id FROM sessions s WHERE s.thread_id = t.id ORDER BY s.created_at DESC LIMIT 1) as current_session_id, \
(SELECT COUNT(*) FROM thread_messages WHERE thread_id = t.id) as message_count \
FROM threads t";
type ThreadRow = (
String,
String,
bool,
Option<String>,
String,
String,
Option<String>,
String,
Option<String>,
i64,
);
fn thread_from_row(
(
id,
name,
user_set_name,
working_dir,
created_at,
updated_at,
archived_at_str,
metadata_json,
current_session_id,
message_count,
): ThreadRow,
) -> Result<Thread> {
let metadata: ThreadMetadata = serde_json::from_str(&metadata_json).unwrap_or_default();
let archived_at = archived_at_str.as_deref().and_then(|s| s.parse().ok());
Ok(Thread {
id,
name,
user_set_name,
working_dir,
created_at: created_at.parse().unwrap_or_else(|_| Utc::now()),
updated_at: updated_at.parse().unwrap_or_else(|_| Utc::now()),
archived_at,
metadata,
current_session_id,
message_count,
})
}
impl ThreadManager {
pub fn new(storage: Arc<SessionStorage>) -> Self {
Self { storage }
}
pub async fn create_thread(
&self,
name: Option<String>,
metadata: Option<ThreadMetadata>,
working_dir: Option<String>,
) -> Result<Thread> {
let pool = self.storage.pool().await?;
let id = uuid::Uuid::new_v4().to_string();
let name = name.unwrap_or_else(|| "New Chat".to_string());
let meta = metadata.unwrap_or_default();
let metadata_json = serde_json::to_string(&meta)?;
sqlx::query(
"INSERT INTO threads (id, name, user_set_name, working_dir, metadata_json) VALUES (?, ?, FALSE, ?, ?)",
)
.bind(&id)
.bind(&name)
.bind(&working_dir)
.bind(&metadata_json)
.execute(pool)
.await?;
self.get_thread(&id).await
}
pub async fn get_thread(&self, id: &str) -> Result<Thread> {
let pool = self.storage.pool().await?;
let sql = format!("{} WHERE t.id = ?", THREAD_SELECT);
let row = sqlx::query_as::<_, ThreadRow>(&sql)
.bind(id)
.fetch_one(pool)
.await?;
thread_from_row(row)
}
pub async fn update_thread(
&self,
id: &str,
name: Option<String>,
user_set_name: Option<bool>,
metadata: Option<ThreadMetadata>,
) -> Result<Thread> {
let pool = self.storage.pool().await?;
let mut sets = Vec::new();
if name.is_some() {
sets.push("name = ?");
sets.push("user_set_name = ?");
}
if metadata.is_some() {
sets.push("metadata_json = ?");
}
if !sets.is_empty() {
let sql = format!(
"UPDATE threads SET {}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
sets.join(", ")
);
let mut q = sqlx::query(&sql);
if let Some(ref n) = name {
q = q.bind(n);
q = q.bind(user_set_name.unwrap_or(true));
}
if let Some(ref meta) = metadata {
q = q.bind(serde_json::to_string(meta)?);
}
q = q.bind(id);
q.execute(pool).await?;
}
self.get_thread(id).await
}
pub async fn list_threads(&self, include_archived: bool) -> Result<Vec<Thread>> {
let pool = self.storage.pool().await?;
let sql = if include_archived {
format!("{} ORDER BY t.updated_at DESC", THREAD_SELECT)
} else {
format!(
"{} WHERE t.archived_at IS NULL ORDER BY t.updated_at DESC",
THREAD_SELECT
)
};
let rows = sqlx::query_as::<_, ThreadRow>(&sql).fetch_all(pool).await?;
rows.into_iter().map(thread_from_row).collect()
}
pub async fn archive_thread(&self, id: &str) -> Result<Thread> {
let pool = self.storage.pool().await?;
sqlx::query("UPDATE threads SET archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
self.get_thread(id).await
}
pub async fn unarchive_thread(&self, id: &str) -> Result<Thread> {
let pool = self.storage.pool().await?;
sqlx::query(
"UPDATE threads SET archived_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
)
.bind(id)
.execute(pool)
.await?;
self.get_thread(id).await
}
pub async fn update_metadata(
&self,
id: &str,
f: impl FnOnce(&mut ThreadMetadata),
) -> Result<Thread> {
let thread = self.get_thread(id).await?;
let mut meta = thread.metadata;
f(&mut meta);
self.update_thread(id, None, None, Some(meta)).await
}
pub async fn update_working_dir(&self, id: &str, working_dir: &str) -> Result<()> {
let pool = self.storage.pool().await?;
sqlx::query(
"UPDATE threads SET working_dir = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
)
.bind(working_dir)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_thread(&self, id: &str) -> Result<()> {
let pool = self.storage.pool().await?;
let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM thread_messages WHERE thread_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE thread_id = ?)",
)
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM sessions WHERE thread_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM threads WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn append_message(
&self,
thread_id: &str,
session_id: Option<&str>,
message: &Message,
) -> Result<Message> {
let pool = self.storage.pool().await?;
let role_str = role_to_string(&message.role);
let metadata_json = serde_json::to_string(&message.metadata)?;
// When the incoming message is text-only, try to coalesce it with the
// last stored row if that row has the same role and is also text-only.
// This avoids storing one row per streaming token while keeping the UI
// streaming path unchanged (callers still forward every chunk).
if message.has_only_text_content() && !message.content.is_empty() {
let new_text = message.as_concat_text();
let maybe_last = sqlx::query_as::<_, (i64, String, String, String, String)>(
"SELECT id, message_id, role, content_json, metadata_json \
FROM thread_messages \
WHERE thread_id = ? \
ORDER BY id DESC LIMIT 1",
)
.bind(thread_id)
.fetch_optional(pool)
.await?;
if let Some((
row_id,
existing_msg_id,
last_role,
last_content_json,
last_metadata_json,
)) = maybe_last
{
if last_role == role_str
&& last_metadata_json == metadata_json
&& is_text_only_json(&last_content_json)
{
// Append text into the existing row's single text element.
let updated_json = append_text_json(&last_content_json, &new_text)?;
sqlx::query("UPDATE thread_messages SET content_json = ? WHERE id = ?")
.bind(&updated_json)
.bind(row_id)
.execute(pool)
.await?;
sqlx::query("UPDATE threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(thread_id)
.execute(pool)
.await?;
let mut stored = message.clone();
stored.id = Some(existing_msg_id);
return Ok(stored);
}
}
}
// Default path: insert a new row.
let content_json = serde_json::to_string(&message.content)?;
let message_id = message
.id
.clone()
.unwrap_or_else(|| format!("tmsg_{}", uuid::Uuid::new_v4()));
sqlx::query(
"INSERT INTO thread_messages (thread_id, session_id, message_id, role, content_json, created_timestamp, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(thread_id)
.bind(session_id)
.bind(&message_id)
.bind(role_str)
.bind(&content_json)
.bind(message.created)
.bind(&metadata_json)
.execute(pool)
.await?;
sqlx::query("UPDATE threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(thread_id)
.execute(pool)
.await?;
let mut stored = message.clone();
stored.id = Some(message_id);
Ok(stored)
}
pub async fn fork_thread(&self, source_thread_id: &str) -> Result<Thread> {
let source = self.get_thread(source_thread_id).await?;
let pool = self.storage.pool().await?;
let new_id = uuid::Uuid::new_v4().to_string();
let name = format!("Fork of {}", source.name);
let metadata_json = serde_json::to_string(&source.metadata)?;
sqlx::query(
"INSERT INTO threads (id, name, user_set_name, working_dir, metadata_json) VALUES (?, ?, FALSE, ?, ?)",
)
.bind(&new_id)
.bind(&name)
.bind(&source.working_dir)
.bind(&metadata_json)
.execute(pool)
.await?;
// Copy all thread messages
sqlx::query(
"INSERT INTO thread_messages (thread_id, session_id, message_id, role, content_json, created_timestamp, metadata_json) \
SELECT ?, session_id, 'tmsg_' || hex(randomblob(16)), role, content_json, created_timestamp, metadata_json \
FROM thread_messages WHERE thread_id = ? ORDER BY id ASC",
)
.bind(&new_id)
.bind(source_thread_id)
.execute(pool)
.await?;
self.get_thread(&new_id).await
}
pub async fn list_messages(&self, thread_id: &str) -> Result<Vec<Message>> {
let pool = self.storage.pool().await?;
let rows = sqlx::query_as::<_, (Option<String>, String, Option<String>, String, i64, String)>(
"SELECT message_id, role, session_id, content_json, created_timestamp, metadata_json FROM thread_messages WHERE thread_id = ? ORDER BY id ASC",
)
.bind(thread_id)
.fetch_all(pool)
.await?;
let mut messages = Vec::new();
for (message_id, role_str, _session_id, content_json, created_timestamp, metadata_json) in
rows
{
let role = match role_str.as_str() {
"user" => Role::User,
"assistant" => Role::Assistant,
_ => continue,
};
let content = serde_json::from_str(&content_json)?;
let metadata = serde_json::from_str(&metadata_json).unwrap_or_default();
let mut msg = Message::new(role, created_timestamp, content);
msg.metadata = metadata;
if let Some(id) = message_id {
msg = msg.with_id(id);
}
messages.push(msg);
}
Ok(messages)
}
}
/// Check whether a `content_json` string represents a single text-only element.
/// Avoids a full deserialize by inspecting the JSON structure directly.
fn is_text_only_json(content_json: &str) -> bool {
let Ok(items) = serde_json::from_str::<Vec<serde_json::Value>>(content_json) else {
return false;
};
items.len() == 1
&& items[0].get("type").and_then(|v| v.as_str()) == Some("text")
&& items[0].get("text").is_some()
}
/// Append `new_text` to the single text element in a text-only `content_json` array.
fn append_text_json(content_json: &str, new_text: &str) -> anyhow::Result<String> {
let mut items: Vec<serde_json::Value> = serde_json::from_str(content_json)?;
if let Some(text_val) = items.get_mut(0).and_then(|v| v.get_mut("text")) {
let existing = text_val.as_str().unwrap_or("");
*text_val = serde_json::Value::String(format!("{}{}", existing, new_text));
}
Ok(serde_json::to_string(&items)?)
}