Files
Skald-Circle/crates/core-api/src/events.rs
T
dguiducci 3343260bb0
Nightly Build / build (push) Successful in 6m59s
token streaming & reasoning display: live SSE tokens frontend to back
- Add StreamDelta(SseDecoder) framing shared by OpenAI/Anthropic
- OpenAiClient: stream=true + reasoning_content deltas, index-based
  tool_calls accumulation, usage from final chunk
- AnthropicClient: message_start/content_block_*/message_delta events,
  thinking_delta->reasoning, input_json_delta->tool input
- TokenDelta ServerEvent variant wired through ChatHub + WS broadcast
- Frontend throttled flush (~15 Hz), pending bubble mutate-in-place,
  reasoning as collapsed-by-default <details>
- Drop streaming bubble on error/llm_failed/model_fallback
- i18n: chat.reasoning key added to en/fr/it
2026-07-22 12:59:13 +01:00

330 lines
14 KiB
Rust

use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::message_meta::Attachment;
// ── Client → Server ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ClientMessage {
pub content: String,
/// Files attached to this message (uploaded beforehand via `POST /api/{source}/uploads`).
#[serde(default)]
pub attachments: Vec<Attachment>,
}
/// Typed data push from remote clients (iOS app, etc.).
/// Sent over the existing WebSocket as `{"type":"data","stream":"...","payload":{...}}`.
#[derive(Deserialize)]
pub struct InboundDataMessage {
pub stream: String,
pub payload: Value,
}
// ── Global event envelope ─────────────────────────────────────────────────────
/// Envelope that wraps every event on the global broadcast bus.
/// `source` is `None` for system/background events (cron, tic, plugins).
#[derive(Clone)]
pub struct GlobalEvent {
pub source: Option<String>,
pub session_id: Option<i64>,
pub event: ServerEvent,
}
// ── Server → Client ───────────────────────────────────────────────────────────
/// Which token stream a [`ServerEvent::TokenDelta`] belongs to.
#[derive(Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenDeltaKind {
/// Visible answer text.
Content,
/// Model chain-of-thought (reasoning/thinking tokens).
Reasoning,
}
#[derive(Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerEvent {
/// A tool call was started. DB status: running.
ToolStart {
tool_call_id: i64,
message_id: i64,
name: String,
arguments: Value,
/// Friendly, static card title ("Edit File", "Read File", or an MCP tool's
/// resolved friendly name). Separate from `name` (the raw LLM function id).
display_name: String,
/// Semantic icon key (`edit`/`read`/`shell`/`mcp`/…) the frontend maps to a
/// glyph + accent color. Never a glyph — the core commits to meaning, not look.
icon: String,
/// Concise human-readable label (≤60 chars): tool + primary argument.
label_short: String,
/// Verbose human-readable label (≤120 chars): tool + all meaningful arguments.
label_full: String,
/// Path to a single viewable file this call targets, if any. The
/// frontend renders it as a clickable link to the file viewer.
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
},
/// A tool call completed successfully. DB status: done.
ToolDone {
tool_call_id: i64,
result: String,
/// Result type tag: `"string"` (plain text) or `"json"` (structured
/// payload, e.g. MCP `structuredContent`). The frontend uses it to render
/// typed results instead of a raw text blob. Always populated by the
/// server; the frontend treats an absent/unknown value as plain text, so
/// older clients degrade gracefully.
result_type: String,
/// For a file-write tool: the file content before/after the write, so the
/// card renders the diff inline even for an auto-allowed write (one that
/// never emitted a `PendingWrite`). Absent for non-write tools.
#[serde(skip_serializing_if = "Option::is_none")]
preview_old: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
preview_new: Option<String>,
},
/// A tool call failed. DB status: error.
ToolError {
tool_call_id: i64,
error: String,
},
/// A tool call was stopped by the user via `/stop`. DB status: cancelled.
/// Distinct from `ToolError`: a cancellation is deliberate, not a failure.
ToolCancelled {
tool_call_id: i64,
},
/// A tool call was denied by an approval policy or a human. DB status: rejected.
/// Distinct from `ToolError`: a denial is a policy decision, not a failure.
ToolRejected {
tool_call_id: i64,
reason: String,
},
/// A sub-agent stack frame was opened.
AgentStart {
stack_id: i64,
parent_tool_call_id: i64,
agent_id: String,
parent_agent_id: String,
depth: i64,
/// The prompt sent to the sub-agent (truncated to 500 chars by the sender).
prompt_preview: String,
},
/// A sub-agent stack frame was closed.
AgentDone {
stack_id: i64,
agent_id: String,
parent_agent_id: String,
/// The sub-agent's final response (truncated to 500 chars by the sender).
result_preview: String,
},
/// The assistant response is complete.
Done {
message_id: i64,
stack_id: i64,
content: String,
input_tokens: Option<u32>,
output_tokens: Option<u32>,
/// Chain-of-thought, when the model produced any. Lets non-streaming
/// providers surface the reasoning block live, not just from history.
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
},
/// A fatal error occurred processing the request.
Error {
message: String,
},
/// The LLM was cut off by the token limit (finish_reason="length").
Truncated {
output_tokens: Option<u32>,
},
/// The LLM produced text alongside tool calls (reasoning before acting).
Thinking {
message_id: i64,
content: String,
input_tokens: Option<u32>,
output_tokens: Option<u32>,
/// Chain-of-thought of this tool-call round, when produced.
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
},
/// One incremental token while the assistant response (or its reasoning)
/// is being generated. Best-effort: deltas ride the lossy broadcast bus and
/// a lagging client may miss some — the final `Done` is always authoritative
/// and carries the complete content.
TokenDelta {
kind: TokenDeltaKind,
delta: String,
},
/// A write operation requires user approval before executing (shows a diff).
PendingWrite {
request_id: i64,
tool_call_id: i64,
path: String,
old_content: Option<String>,
new_content: String,
},
/// A non-file tool call requires user approval before executing.
/// Used for MCP tools, execute_cmd, restart, and any other tool
/// that the ApprovalManager flags as `Require`.
ApprovalRequired {
request_id: i64,
tool_call_id: i64,
tool_name: String,
arguments: Value,
},
/// A sub-agent needs clarification from the user before continuing.
AgentQuestion {
request_id: i64,
tool_call_id: i64,
title: String,
question: String,
suggested_answers: Vec<String>,
},
/// A book file was written by a tool; the frontend should reload if it has it open.
FileChanged {
path: String,
},
/// Ask the frontend to open a file for the user, via `window.openFile(path)`:
/// the file-viewer page renders every kind (markdown / text / images / SVG /
/// PDF / LaTeX compiled server-side, and HTML live in an origin-isolated
/// iframe). Emitted by the `show_file_to_user` interface tool; `path` is the
/// caller's canonical **agent path** (`~/…`, `shared/{X}/…`, `projects/…`),
/// which the viewer fetches back through `GET /api/file`.
OpenFile {
path: String,
},
/// The active LLM model failed and the system switched to a fallback automatically.
ModelFallback {
from: String,
to: String,
reason: String,
},
/// All LLM fallback attempts were exhausted; the turn could not complete.
LlmFailed {
tried: Vec<String>,
last_error: String,
},
/// A new approval entered the Inbox. Emitted on the global bus when the
/// `ApprovalManager` registers a pending request, so bus subscribers (e.g.
/// the mobile-connector plugin) can re-snapshot the Inbox. Distinct from
/// `ApprovalRequired`, which is the per-session WS event carrying full args
/// for the active client.
ApprovalRequested {
request_id: i64,
tool_call_id: i64,
tool_name: String,
},
/// A pending approval or pending-write was resolved (approved or rejected).
/// Emitted on the global bus so all clients (e.g. Telegram) can update their UI.
ApprovalResolved {
request_id: i64,
tool_call_id: i64,
approved: bool,
},
/// A new clarification entered the Inbox. Emitted on the global bus when the
/// `ClarificationManager` registers a pending question. Distinct from
/// `AgentQuestion`, which is the per-session WS event for the active client.
ClarificationRequested {
request_id: i64,
title: String,
},
/// A pending clarification was resolved (answered). Emitted on the global bus
/// so all clients can update their Inbox view.
ClarificationResolved {
request_id: i64,
},
/// A server-initiated MCP elicitation entered the Inbox (e.g. an MCP server
/// asking for a sudo password mid tool-call). Carries only `request_id` +
/// `title` — never the requested value.
ElicitationRequested {
request_id: i64,
title: String,
},
/// A pending elicitation was resolved (accepted / declined / cancelled).
/// Emitted on the global bus so all clients can update their Inbox view.
ElicitationResolved {
request_id: i64,
},
/// The active session for a source was replaced (e.g. /new, /clear).
NewSession {
session_id: i64,
},
/// A user message was persisted to history; broadcast so every client (the
/// sender included) renders the bubble. Emitted at save time — when the row
/// is appended, either at turn start or at a round boundary for messages
/// injected mid-turn — so the bubble appears exactly where the agent saw it.
/// Clients render purely from this echo (no optimistic local rendering).
UserMessage {
/// Id of the `chat_history` row just written.
message_id: i64,
content: String,
/// Files attached to the message; lets secondary clients render chips live.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<Attachment>,
},
/// Sent to a client right after it (re)connects, reporting whether a turn is
/// currently in flight for its session. Lets a reloaded page restore the
/// SEND→STOP button state instead of assuming idle.
TurnRunning {
running: bool,
},
/// The selected LLM client (model) for a source changed. Broadcast to every
/// client of the source so dropdowns/selects stay in sync. `client` is a
/// `client_names()` entry (typically `"auto"` or a model name). Driven by
/// `ChatHub::set_selected_client` — the backend is the single source of truth.
ClientSelected {
client: String,
},
/// The session security-group (permission group) changed. Broadcast to every
/// client of the source so the chat picker stays in sync — the twin of
/// `ClientSelected` for the model. `group` is the effective group id
/// (`"default"` when cleared). The backend is the single source of truth.
SecurityGroupSelected {
group: String,
},
}
impl ServerEvent {
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("ServerEvent serialization failed")
}
pub fn type_name(&self) -> &'static str {
match self {
Self::ToolStart { .. } => "tool_start",
Self::ToolDone { .. } => "tool_done",
Self::ToolError { .. } => "tool_error",
Self::ToolCancelled { .. } => "tool_cancelled",
Self::ToolRejected { .. } => "tool_rejected",
Self::AgentStart { .. } => "agent_start",
Self::AgentDone { .. } => "agent_done",
Self::Done { .. } => "done",
Self::Error { .. } => "error",
Self::Thinking { .. } => "thinking",
Self::TokenDelta { .. } => "token_delta",
Self::PendingWrite { .. } => "pending_write",
Self::ApprovalRequired { .. } => "approval_required",
Self::AgentQuestion { .. } => "agent_question",
Self::FileChanged { .. } => "file_changed",
Self::OpenFile { .. } => "open_file",
Self::Truncated { .. } => "truncated",
Self::ModelFallback { .. } => "model_fallback",
Self::LlmFailed { .. } => "llm_failed",
Self::ApprovalRequested { .. } => "approval_requested",
Self::ApprovalResolved { .. } => "approval_resolved",
Self::ClarificationRequested { .. } => "clarification_requested",
Self::ClarificationResolved { .. } => "clarification_resolved",
Self::ElicitationRequested { .. } => "elicitation_requested",
Self::ElicitationResolved { .. } => "elicitation_resolved",
Self::NewSession { .. } => "new_session",
Self::UserMessage { .. } => "user_message",
Self::TurnRunning { .. } => "turn_running",
Self::ClientSelected { .. } => "client_selected",
Self::SecurityGroupSelected { .. } => "security_group_selected",
}
}
}