Files
Skald-Circle/src/frontend/api/sessions.rs
T
dguiducci 0ba140186f
Nightly Build / build (push) Successful in 6m58s
auth: make deactivation and group revocation actually revoke
Two variants of the same defect: an admin took away access and the running
system kept granting it.

Deactivating or deleting a user only stopped the *next* login. `login`
checks the active flag, but `require_auth` maps token -> id without
re-reading the row, so an already-open session kept working over a pool
whose key was still in RAM. There was no way to stop one user either: the
per-user cron, hub and MCP loops all observed the *instance* shutdown
token. They now take a per-user child token stored on UserContext, and
Skald::revoke_user_runtime tears a single user down in a load-bearing
order — revoke every session, evict and cancel the context, then lock the
database, so nothing is left querying a pool we are about to close.

Revoking a security group had a durable version of the same problem. The
group is validated when selected and then persisted on
chat_sessions.run_context, which was replayed verbatim on every later
load — so a group removed from a role stayed in force on sessions that
already had it, across restarts. get_or_create_handler now runs the stored
value through run_context::reconcile_group_for_user, making it advisory:
every load re-checks it, whether or not anyone announced the change.

The degrade target is the role's default group, never None: a context with
no group resolves to the catch-all `default`, whose rules are the fallback
tier under every other group, so clearing widens rather than narrows. The
reconcile touches only security_group, so a project session's server-built
project_root and system_prompt survive a permissions edit, and it leaves
the stored group alone when the role cannot be resolved — guessing on a
transient error could only widen. role_default_run_context moves into the
core seam so the group a session starts on and the group it falls back to
cannot drift apart.

Both fixes run synchronously in their handlers. Only the container half of
deactivation rides the bus, as the new UserActiveChanged event: a lossy
64-slot broadcast whose contract is "settles at the next login" is the
wrong transport for taking access away.

Tests: revoke_user drops all of one user's sessions and nobody else's, and
is a no-op when nothing is live; the reconcile degrades a revoked group to
the role default, keeps an allowed one, preserves project fields in both
directions, never touches an admin, and stays put when the role is
unresolvable.

Not exercised at runtime: no Docker/live-server run, so the end-to-end
paths (deactivating a logged-in user, editing a role with sessions open)
are covered by unit tests only.
2026-07-26 22:17:30 +01:00

714 lines
32 KiB
Rust

use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use axum::{
Json, Extension,
extract::{Path, Query, State},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources};
use skald_core::db::chat_sessions_stack::SessionStack;
use skald_core::run_context::RunContext;
use std::sync::Arc;
use skald_core::skald::{Skald, UserContext};
use skald_core::session::handler::ApprovalDecision;
use skald_core::approval::ApprovalManager;
use skald_core::mcp::{McpManager, parse_mcp_tool_name};
use skald_core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn};
use super::{ApiError, guard::AuthUser, require_context};
// ── POST /api/sessions — start a new conversation ─────────────────────────────
#[derive(Deserialize)]
pub struct CreateQuery {
#[serde(default = "default_source")]
pub source: String,
}
fn default_source() -> String { "web".to_string() }
pub async fn create(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(q): Query<CreateQuery>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
// Resolve agent + RunContext from the source so project chats reset with the
// coordinator agent (not the default `main`), then provision a fresh session.
let (agent, rc) = super::projects::provisioning_for_source(&skald, &auth.user_id, &q.source).await?;
// A non-project chat inherits the caller role's default security-group, so a
// restricted role starts scoped instead of on the catch-all `default` group.
// Project chats already carry their own run-context and are left untouched.
let rc = match rc {
Some(rc) => Some(rc),
None => role_default_run_context(&skald, &auth.user_id).await?,
};
ctx.chat_hub.provision_session(&q.source, &agent, rc.as_ref(), true).await?;
Ok(Json(json!({})))
}
/// The default security-group a new session gets from the owner's role, or `None`
/// when the role points at the catch-all `default` group (nothing to pin).
///
/// Thin wrapper over the core seam, which is also what
/// [`reconcile_group_for_user`](skald_core::run_context::reconcile_group_for_user)
/// degrades *to* — so the group a session starts on and the group it falls back to
/// after a revocation can never drift apart.
async fn role_default_run_context(
skald: &Skald,
user_id: &str,
) -> Result<Option<RunContext>, ApiError> {
Ok(skald_core::run_context::role_default_run_context(skald.db(), user_id).await)
}
// ── GET /api/web/messages ─────────────────────────────────────────────────────
pub async fn web_messages(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<Value>>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
messages_for_source(&skald, &ctx, "web").await
}
// ── GET /api/:source/messages ─────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct SourcePath { pub source: String }
pub async fn source_messages(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<SourcePath>,
) -> Result<Json<Vec<Value>>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
messages_for_source(&skald, &ctx, &p.source).await
}
async fn messages_for_source(skald: &Arc<Skald>, ctx: &UserContext, source: &str) -> Result<Json<Vec<Value>>, ApiError> {
// History/sessions read from the caller's own pool; the tool registry is a
// global capability, and approval is this user's per-user manager.
let db = &ctx.pool;
let session_id = match sources::active_session_id(db, source).await? {
Some(id) => id,
None => return Ok(Json(vec![])),
};
let main_stack = match chat_sessions_stack::main_for_session(db, session_id).await? {
Some(s) => s,
None => return Ok(Json(vec![])),
};
let subagent_map: HashMap<i64, SessionStack> =
chat_sessions_stack::all_for_session(db, session_id)
.await?
.into_iter()
.filter_map(|s| s.parent_tool_call_id.map(|tc_id| (tc_id, s)))
.collect();
let mut items: Vec<Value> = Vec::new();
build_items(db, skald.tools(), skald.mcp(), &ctx.user_mcp, &ctx.approval, &main_stack, &subagent_map, &mut items).await?;
Ok(Json(items))
}
/// Card metadata (friendly display name + semantic icon key) for a persisted tool
/// call, mirroring the live loop's `tool_ui_meta`: the registry seam, with the MCP
/// display-name override layered on (per-user runtime first, then global) for an
/// `mcp__server__tool` name. History resolves against the running runtimes, so the
/// friendly name survives a refresh; a stopped server falls back to the prettified
/// name the seam produced.
fn tool_card_meta(
tools: &ToolRegistry,
global_mcp: &McpManager,
user_mcp: &McpManager,
name: &str,
args: &Value,
) -> (String, String) {
let mut meta = tools.display_meta(name, args);
if let Some((server, tool)) = parse_mcp_tool_name(name) {
if let Some(friendly) = user_mcp.tool_display_name(server, tool)
.or_else(|| global_mcp.tool_display_name(server, tool))
{
meta.display_name = friendly;
}
}
(meta.display_name, meta.icon)
}
// ── POST /api/tools/:tool_call_id/resolve — approve/reject a pending tool ─────
// (source-agnostic; /api/web/tools/... kept as a back-compat alias)
#[derive(Deserialize)]
pub struct ResolveToolPath {
pub tool_call_id: i64,
}
#[derive(Deserialize)]
pub struct ResolveToolBody {
/// `"approve"` or `"reject"`
pub action: String,
#[serde(default)]
pub note: String,
}
#[derive(Serialize)]
pub struct ResolveToolResponse {
pub tool_call_id: i64,
pub status: String,
pub result: Option<String>,
/// Result type tag (`"string"` | `"json"`) so the frontend keeps rich JSON
/// rendering after resolving an approval, matching the live `ToolDone` event.
pub result_type: String,
}
/// Approve or reject a `pending` tool call, resolved by its globally-unique
/// `tool_call_id`. Source-agnostic: the owning session is derived from the tool
/// call's own stack row, so approvals from any client (web/mobile/telegram/cron)
/// resolve against the correct session — there is no "current session" scoping.
pub async fn resolve_tool(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ResolveToolPath>,
Json(body): Json<ResolveToolBody>,
) -> Result<Json<ResolveToolResponse>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let db = &ctx.pool;
// Look up the tool call by id alone — no active-session filter. Also pull the
// owning session_id so the post-restart path drives the correct session.
let tc = sqlx::query_as::<_, (i64, String, Option<String>, String, i64)>(
"SELECT t.id, t.name, t.arguments, t.status, ss.session_id
FROM chat_llm_tools t
JOIN chat_history h ON h.id = t.message_id
JOIN chat_sessions_stack ss ON ss.id = h.session_stack_id
WHERE t.id = ?",
)
.bind(p.tool_call_id)
.fetch_optional(&**db)
.await?
.ok_or_else(|| anyhow::anyhow!(
"tool_call_id {} not found", p.tool_call_id
))?;
let (tc_id, tc_name, tc_args_raw, tc_status, session_id) = tc;
if tc_status != "pending" {
return Err(anyhow::anyhow!(
"tool_call {} is not pending (status: {})", tc_id, tc_status
).into());
}
let args: Value = tc_args_raw.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Object(Default::default()));
if body.action == "reject" {
// Pass the raw user note to the live session so the loop builds the
// canonical message; for the not-live path (no waiting session, e.g.
// after a restart) build the same message here and save it directly.
let live = ctx.approval
.resolve_for_tool_call(tc_id, ApprovalDecision::Rejected { note: body.note.clone() })
.await;
let msg = ApprovalDecision::rejection_message(&body.note);
if !live {
chat_llm_tools::reject(db, tc_id, &msg).await?;
// The refusal is part of the conversation: let the model read it and
// carry on, instead of leaving the turn dead where it stopped.
let hub = ctx.chat_hub.clone();
tokio::spawn(async move {
if let Err(e) = hub.resume_session(session_id).await {
tracing::warn!(session_id, tool_call_id = tc_id, error = %e,
"post-restart continue after rejection failed");
}
});
}
return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "rejected".to_string(),
result: Some(msg),
result_type: "string".to_string(),
}));
}
// ── Live path: LLM loop is blocked waiting for approval ──────────────────
if ctx.approval
.resolve_for_tool_call(tc_id, ApprovalDecision::Approved)
.await
{
return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "running".to_string(),
result: None,
result_type: "string".to_string(),
}));
}
// ── Post-restart path: no in-memory oneshot to unblock. ───────────────────
// One path for every tool: the loop's `resolve_pending` runs the call with
// the gate skipped (the human just decided) but with this session's real
// context — owner pool, per-user container — then continues the turn. A
// sub-agent dispatch works here too: it opens its child frame like any
// other call. Events stream to the reconnected client through the global
// bus, so the endpoint returns as soon as the work is scheduled.
let hub = ctx.chat_hub.clone();
tokio::spawn(async move {
if let Err(e) = hub
.resolve_pending_call(session_id, tc_id, ApprovalDecision::Approved)
.await
{
tracing::warn!(session_id, tool_call_id = tc_id, error = %e,
"post-restart approval failed");
}
});
Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "running".to_string(),
result: None,
result_type: "string".to_string(),
}))
}
// ── GET /api/tools/:tool_call_id — full execution detail for the detail page ──
/// One tool call's full record — input args, result, and (for a file-write) the
/// before/after snapshot — for the dedicated tool-detail page. Read from the
/// caller's own pool (the `tool_call_id` is local to it), so ownership is implicit.
pub async fn tool_detail(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ResolveToolPath>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let tc = chat_llm_tools::get(&ctx.pool, p.tool_call_id).await?
.ok_or_else(|| ApiError::not_found(format!("tool_call_id {} not found", p.tool_call_id)))?;
let args: Value = tc.arguments.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
// Normalize the DB status the same way as the history projection: an interrupted
// `running` row surfaces as an error, terminal states pass through.
let (status, result, error) = match tc.status.as_str() {
"done" => ("done", tc.result.clone(), None),
"pending" => ("pending", None, None),
"running" => ("error", None, Some("Interrupted.".to_string())),
"cancelled" => ("cancelled", None, tc.result.clone()),
"rejected" => ("rejected", None, tc.result.clone()),
_ => ("error", None, tc.result.clone()),
};
let (display_name, icon) = tool_card_meta(skald.tools(), skald.mcp(), &ctx.user_mcp, &tc.name, &args);
let label_full = skald.tools().describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = skald.tools().target_path(&tc.name, &args);
Ok(Json(json!({
"tool_call_id": tc.id,
"name": tc.name,
"display_name": display_name,
"icon": icon,
"label_full": label_full,
"path": target_path,
"arguments": args,
"status": status,
"result": result,
"result_type": tc.result_type,
"error": error,
"preview_old": tc.preview_old,
"preview_new": tc.preview_new,
})))
}
// ── GET /api/sessions — list sessions by source (paginated) ──────────────────
#[derive(Deserialize)]
pub struct ListSessionsQuery {
pub source: Option<String>,
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_per_page")]
pub per_page: i64,
}
fn default_page() -> i64 { 1 }
fn default_per_page() -> i64 { 20 }
pub async fn list_sessions(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(q): Query<ListSessionsQuery>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let db = &ctx.pool;
let per_page = q.per_page.max(1).min(100);
let offset = ((q.page.max(1)) - 1) * per_page;
let src = q.source.as_deref();
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM chat_sessions cs
WHERE (? IS NULL OR cs.source = ?)",
)
.bind(src).bind(src)
.fetch_one(&**db).await?;
let rows = sqlx::query_as::<_, (i64, String, String, bool, bool, Option<String>, i64, Option<String>)>(
"SELECT cs.id, cs.source, cs.agent_id, cs.is_ephemeral, cs.is_interactive,
cs.created_at,
COUNT(h.id) AS message_count,
MAX(h.created_at) AS last_message_at
FROM chat_sessions cs
LEFT JOIN chat_sessions_stack ss ON ss.session_id = cs.id AND ss.depth = 0
LEFT JOIN chat_history h ON h.session_stack_id = ss.id AND h.status = 'ok'
WHERE (? IS NULL OR cs.source = ?)
GROUP BY cs.id
ORDER BY cs.id DESC
LIMIT ? OFFSET ?",
)
.bind(src).bind(src)
.bind(per_page).bind(offset)
.fetch_all(&**db).await?;
let items: Vec<Value> = rows.into_iter().map(|(id, source, agent_id, is_ephemeral, is_interactive, created_at, message_count, last_message_at)| {
json!({
"id": id,
"source": source,
"agent_id": agent_id,
"is_ephemeral": is_ephemeral,
"is_interactive": is_interactive,
"created_at": created_at,
"message_count": message_count,
"last_message_at": last_message_at,
})
}).collect();
Ok(Json(json!({
"items": items,
"total": total,
"page": q.page.max(1),
"per_page": per_page,
})))
}
// ── GET /api/sessions/:id — read-only session detail (debug view) ─────────────
#[derive(Deserialize)]
pub struct SessionIdPath { pub id: i64 }
pub async fn get_session_detail(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<SessionIdPath>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let db = &ctx.pool;
let session = chat_sessions::find_by_id(db, p.id)
.await?
.ok_or_else(|| ApiError::not_found(format!("session {} not found", p.id)))?;
let created_at: Option<String> = sqlx::query_scalar(
"SELECT created_at FROM chat_sessions WHERE id = ?",
)
.bind(p.id)
.fetch_optional(&**db)
.await?;
let all_stacks = chat_sessions_stack::all_for_session(db, session.id).await?;
let subagent_map: HashMap<i64, SessionStack> = all_stacks
.iter()
.filter_map(|s| s.parent_tool_call_id.map(|tc_id| (tc_id, s.clone())))
.collect();
let main_stack = match all_stacks.into_iter().find(|s| s.depth == 0) {
Some(s) => s,
None => return Ok(Json(json!({
"session": {
"id": session.id, "source": session.source,
"agent_id": session.agent_id, "created_at": created_at,
},
"messages": [],
}))),
};
let mut messages: Vec<Value> = Vec::new();
build_debug_items(db, skald.tools(), &main_stack, &subagent_map, &mut messages).await?;
Ok(Json(json!({
"session": {
"id": session.id,
"source": session.source,
"agent_id": session.agent_id,
"is_interactive": session.is_interactive,
"is_ephemeral": session.is_ephemeral,
"created_at": created_at,
},
"messages": messages,
})))
}
/// Like `build_items` but includes synthetic user messages and reasoning content.
/// Used exclusively by the session-detail debug view.
fn build_debug_items<'a>(
db: &'a SqlitePool,
tools: &'a ToolRegistry,
stack: &'a SessionStack,
subagent_map: &'a HashMap<i64, SessionStack>,
items: &'a mut Vec<Value>,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
Box::pin(async move {
let messages = chat_history::for_stack_all(db, stack.id).await?;
for msg in &messages {
let failed = msg.status == "failed";
match msg.role {
chat_history::Role::User => {
let attachments = msg.metadata.as_ref()
.map(|m| m.attachments.clone())
.unwrap_or_default();
// Custom slash commands render the typed command, not the
// expanded template persisted for LLM replay.
let content = msg.metadata.as_ref()
.and_then(|m| m.command.as_ref())
.map(|c| c.display.clone())
.unwrap_or_else(|| msg.content.clone());
items.push(json!({
"kind": "user",
"content": content,
"attachments": attachments,
"failed": failed,
"is_synthetic": msg.is_synthetic,
"created_at": msg.created_at,
}));
}
chat_history::Role::Agent => {}
chat_history::Role::Assistant => {
let tool_calls = chat_llm_tools::for_message(db, msg.id).await?;
if tool_calls.is_empty() {
items.push(json!({
"kind": "assistant",
"content": msg.content,
"reasoning": msg.reasoning_content,
"failed": failed,
"input_tokens": msg.input_tokens,
"output_tokens": msg.output_tokens,
"created_at": msg.created_at,
}));
} else {
if !msg.content.trim().is_empty() || msg.reasoning_content.is_some() {
items.push(json!({
"kind": "thinking",
"message_id": msg.id,
"content": msg.content,
"reasoning": msg.reasoning_content,
"failed": failed,
"input_tokens": msg.input_tokens,
"output_tokens": msg.output_tokens,
"created_at": msg.created_at,
}));
}
for tc in &tool_calls {
let args: Value = tc.arguments.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
let (status, result, error) = match tc.status.as_str() {
"done" => ("done", tc.result.clone(), None),
"pending" => ("pending", None, None),
"running" => ("error", None, Some("Interrupted.".to_string())),
"cancelled" => ("cancelled", None, tc.result.clone()),
"rejected" => ("rejected", None, tc.result.clone()),
_ => ("error", None, tc.result.clone()),
};
let label_short = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short);
let label_full = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = tools.target_path(&tc.name, &args);
// Debug view has no MCP runtime handy: use the registry seam
// directly (MCP tools fall back to a prettified name + `mcp`
// icon, no live/manifest title override).
let meta = tools.display_meta(&tc.name, &args);
items.push(json!({
"kind": "tool",
"tool_call_id": tc.id,
"name": tc.name,
"display_name": meta.display_name,
"icon": meta.icon,
"label_short": label_short,
"label_full": label_full,
"path": target_path,
"arguments": args,
"status": status,
"result": result,
"result_type": tc.result_type,
"error": error,
"preview_old": tc.preview_old,
"preview_new": tc.preview_new,
}));
if let Some(sub_stack) = subagent_map.get(&tc.id) {
items.push(json!({
"kind": "agent",
"stack_id": sub_stack.id,
"agent_id": sub_stack.agent_id,
"depth": sub_stack.depth,
"done": true,
}));
build_debug_items(db, tools, sub_stack, subagent_map, items).await?;
items.push(json!({
"kind": "agent_end",
"agent_id": sub_stack.agent_id,
"depth": sub_stack.depth,
}));
}
}
}
}
}
}
Ok(())
})
}
// ── Recursive message-tree builder ────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
fn build_items<'a>(
db: &'a SqlitePool,
tools: &'a ToolRegistry,
global_mcp: &'a McpManager,
user_mcp: &'a McpManager,
approval: &'a ApprovalManager,
stack: &'a SessionStack,
subagent_map: &'a HashMap<i64, SessionStack>,
items: &'a mut Vec<Value>,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
Box::pin(async move {
let messages = chat_history::for_stack_all(db, stack.id).await?;
for msg in &messages {
let failed = msg.status == "failed";
match msg.role {
chat_history::Role::User => {
// Skip synthetic messages (TIC notifications, etc.) — they are
// injected as user turns for the LLM but must not appear in the UI.
if msg.is_synthetic {
continue;
}
// `content` stays clean (typed text); attachments are surfaced
// structurally so the UI renders chips, not the LLM-facing block.
let attachments = msg.metadata.as_ref()
.map(|m| m.attachments.clone())
.unwrap_or_default();
// Custom slash commands render the typed command, not the
// expanded template persisted for LLM replay.
let content = msg.metadata.as_ref()
.and_then(|m| m.command.as_ref())
.map(|c| c.display.clone())
.unwrap_or_else(|| msg.content.clone());
items.push(json!({ "kind": "user", "content": content, "attachments": attachments, "failed": failed }));
}
chat_history::Role::Agent => {}
chat_history::Role::Assistant => {
let tool_calls = chat_llm_tools::for_message(db, msg.id).await?;
if tool_calls.is_empty() {
items.push(json!({
"kind": "assistant",
"content": msg.content,
"failed": failed,
"input_tokens": msg.input_tokens,
"output_tokens": msg.output_tokens,
"reasoning": msg.reasoning_content,
}));
} else {
if !msg.content.trim().is_empty() {
items.push(json!({
"kind": "thinking",
"message_id": msg.id,
"content": msg.content,
"failed": failed,
"input_tokens": msg.input_tokens,
"output_tokens": msg.output_tokens,
"reasoning": msg.reasoning_content,
}));
}
for tc in &tool_calls {
let args: Value = tc.arguments.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
let (status, result, error) = match tc.status.as_str() {
"done" => ("done", tc.result.clone(), None),
// 'pending' means waiting for explicit user input (approval or
// clarification) — show the approval form with no error message.
"pending" => ("pending", None, None),
// 'running' means the tool was mid-execution when the session was
// interrupted — shown as "Interrupted" so the frontend can auto-resume.
"running" => ("error", None, Some("Interrupted.".to_string())),
// 'failed' means the tool completed with a genuine error — show
// the actual error message, NOT "Interrupted" (that would trigger
// a spurious auto-resume on page refresh).
_ => ("error", None, tc.result.clone()),
};
// A pending tool is awaiting approval. Surface the live
// `request_id` (only present while the server is up) so the
// client resolves via the source-agnostic WS/Inbox path with
// bypass support; `null` → the client falls back to resolving
// by the durable `tool_call_id`.
let request_id = if status == "pending" {
approval.request_id_for_tool_call(tc.id).await
} else {
None
};
let label_short = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short);
let label_full = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = tools.target_path(&tc.name, &args);
let (display_name, icon) = tool_card_meta(tools, global_mcp, user_mcp, &tc.name, &args);
items.push(json!({
"kind": "tool",
"tool_call_id": tc.id,
"request_id": request_id,
"name": tc.name,
"display_name": display_name,
"icon": icon,
"label_short": label_short,
"label_full": label_full,
"path": target_path,
"arguments": args,
"status": status,
"result": result,
"result_type": tc.result_type,
"error": error,
"preview_old": tc.preview_old,
"preview_new": tc.preview_new,
}));
if let Some(sub_stack) = subagent_map.get(&tc.id) {
items.push(json!({
"kind": "agent",
"stack_id": sub_stack.id,
"agent_id": sub_stack.agent_id,
"depth": sub_stack.depth,
"done": true,
}));
build_items(db, tools, global_mcp, user_mcp, approval, sub_stack, subagent_map, items).await?;
items.push(json!({
"kind": "agent_end",
"agent_id": sub_stack.agent_id,
"depth": sub_stack.depth,
}));
}
}
}
}
}
}
Ok(())
})
}