-
Notifications
You must be signed in to change notification settings - Fork 1.4k
refactor: extract AppEvent to crates/ironclaw_common #1615
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
059445d
c046401
3e8afb1
e0cad3b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| [package] | ||
| name = "ironclaw_common" | ||
| version = "0.1.0" | ||
| edition = "2024" | ||
| rust-version = "1.92" | ||
| description = "Shared types and utilities for the IronClaw workspace" | ||
| authors = ["NEAR AI <support@near.ai>"] | ||
| license = "MIT OR Apache-2.0" | ||
| homepage = "https://github.com/nearai/ironclaw" | ||
| repository = "https://github.com/nearai/ironclaw" | ||
| publish = false | ||
|
|
||
| [package.metadata.dist] | ||
| dist = false | ||
|
|
||
| [dependencies] | ||
| serde = { version = "1", features = ["derive"] } | ||
| serde_json = "1" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| //! Application-wide event types. | ||
| //! | ||
| //! `AppEvent` is the real-time event protocol used across the entire | ||
| //! application. The web gateway serialises these to SSE / WebSocket | ||
| //! frames, but other subsystems (agent loop, orchestrator, extensions) | ||
| //! produce and consume them too. | ||
|
|
||
| use serde::Serialize; | ||
|
|
||
| #[derive(Debug, Clone, Serialize)] | ||
| #[serde(tag = "type")] | ||
| pub enum AppEvent { | ||
| #[serde(rename = "response")] | ||
| Response { content: String, thread_id: String }, | ||
| #[serde(rename = "thinking")] | ||
| Thinking { | ||
| message: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| }, | ||
| #[serde(rename = "tool_started")] | ||
| ToolStarted { | ||
| name: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| }, | ||
| #[serde(rename = "tool_completed")] | ||
| ToolCompleted { | ||
| name: String, | ||
| success: bool, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| error: Option<String>, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| parameters: Option<String>, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| }, | ||
| #[serde(rename = "tool_result")] | ||
| ToolResult { | ||
| name: String, | ||
| preview: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| }, | ||
| #[serde(rename = "stream_chunk")] | ||
| StreamChunk { | ||
| content: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| }, | ||
| #[serde(rename = "status")] | ||
| Status { | ||
| message: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| }, | ||
| #[serde(rename = "job_started")] | ||
| JobStarted { | ||
| job_id: String, | ||
| title: String, | ||
| browse_url: String, | ||
| }, | ||
| #[serde(rename = "approval_needed")] | ||
| ApprovalNeeded { | ||
| request_id: String, | ||
| tool_name: String, | ||
| description: String, | ||
| parameters: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| /// Whether the "always" auto-approve option should be shown. | ||
| allow_always: bool, | ||
| }, | ||
| #[serde(rename = "auth_required")] | ||
| AuthRequired { | ||
| extension_name: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| instructions: Option<String>, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| auth_url: Option<String>, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| setup_url: Option<String>, | ||
| }, | ||
| #[serde(rename = "auth_completed")] | ||
| AuthCompleted { | ||
| extension_name: String, | ||
| success: bool, | ||
| message: String, | ||
| }, | ||
| #[serde(rename = "error")] | ||
| Error { | ||
| message: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| }, | ||
| #[serde(rename = "heartbeat")] | ||
| Heartbeat, | ||
|
|
||
| // Sandbox job streaming events (worker + Claude Code bridge) | ||
| #[serde(rename = "job_message")] | ||
| JobMessage { | ||
| job_id: String, | ||
| role: String, | ||
| content: String, | ||
| }, | ||
| #[serde(rename = "job_tool_use")] | ||
| JobToolUse { | ||
| job_id: String, | ||
| tool_name: String, | ||
| input: serde_json::Value, | ||
| }, | ||
| #[serde(rename = "job_tool_result")] | ||
| JobToolResult { | ||
| job_id: String, | ||
| tool_name: String, | ||
| output: String, | ||
| }, | ||
| #[serde(rename = "job_status")] | ||
| JobStatus { job_id: String, message: String }, | ||
| #[serde(rename = "job_result")] | ||
| JobResult { | ||
| job_id: String, | ||
| status: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| session_id: Option<String>, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| fallback_deliverable: Option<serde_json::Value>, | ||
| }, | ||
|
|
||
| /// An image was generated by a tool. | ||
| #[serde(rename = "image_generated")] | ||
| ImageGenerated { | ||
| data_url: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| path: Option<String>, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| }, | ||
|
|
||
| /// Suggested follow-up messages for the user. | ||
| #[serde(rename = "suggestions")] | ||
| Suggestions { | ||
| suggestions: Vec<String>, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| }, | ||
|
|
||
| /// Per-turn token usage and cost summary. | ||
| #[serde(rename = "turn_cost")] | ||
| TurnCost { | ||
| input_tokens: u64, | ||
| output_tokens: u64, | ||
| cost_usd: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| thread_id: Option<String>, | ||
| }, | ||
|
|
||
| /// Extension activation status change (WASM channels). | ||
| #[serde(rename = "extension_status")] | ||
| ExtensionStatus { | ||
| extension_name: String, | ||
| status: String, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| message: Option<String>, | ||
| }, | ||
| } | ||
|
|
||
| impl AppEvent { | ||
| /// The wire-format event type string (matches the `#[serde(rename)]` value). | ||
| pub fn event_type(&self) -> &'static str { | ||
| match self { | ||
| Self::Response { .. } => "response", | ||
| Self::Thinking { .. } => "thinking", | ||
| Self::ToolStarted { .. } => "tool_started", | ||
| Self::ToolCompleted { .. } => "tool_completed", | ||
| Self::ToolResult { .. } => "tool_result", | ||
| Self::StreamChunk { .. } => "stream_chunk", | ||
| Self::Status { .. } => "status", | ||
| Self::JobStarted { .. } => "job_started", | ||
| Self::ApprovalNeeded { .. } => "approval_needed", | ||
| Self::AuthRequired { .. } => "auth_required", | ||
| Self::AuthCompleted { .. } => "auth_completed", | ||
| Self::Error { .. } => "error", | ||
| Self::Heartbeat => "heartbeat", | ||
| Self::JobMessage { .. } => "job_message", | ||
| Self::JobToolUse { .. } => "job_tool_use", | ||
| Self::JobToolResult { .. } => "job_tool_result", | ||
| Self::JobStatus { .. } => "job_status", | ||
| Self::JobResult { .. } => "job_result", | ||
| Self::ImageGenerated { .. } => "image_generated", | ||
| Self::Suggestions { .. } => "suggestions", | ||
| Self::TurnCost { .. } => "turn_cost", | ||
| Self::ExtensionStatus { .. } => "extension_status", | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| //! Shared types and utilities for the IronClaw workspace. | ||
|
|
||
| mod event; | ||
| mod util; | ||
|
|
||
| pub use event::AppEvent; | ||
| pub use util::truncate_preview; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,100 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| //! Shared utility functions. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Truncate a string to at most `max_bytes` bytes at a char boundary, appending "...". | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// If the input is wrapped in `<tool_output ...>...</tool_output>` and truncation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// removes the closing tag, the tag is re-appended so downstream XML parsers | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// never see an unclosed element. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pub fn truncate_preview(s: &str, max_bytes: usize) -> String { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if s.len() <= max_bytes { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return s.to_string(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Walk backwards from max_bytes to find a valid char boundary | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let mut end = max_bytes; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| while end > 0 && !s.is_char_boundary(end) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| end -= 1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let mut result = format!("{}...", &s[..end]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Re-close <tool_output> if truncation cut through the closing tag. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if s.starts_with("<tool_output") && !result.ends_with("</tool_output>") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+12
to
+20
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Walk backwards from max_bytes to find a valid char boundary | |
| let mut end = max_bytes; | |
| while end > 0 && !s.is_char_boundary(end) { | |
| end -= 1; | |
| } | |
| let mut result = format!("{}...", &s[..end]); | |
| // Re-close <tool_output> if truncation cut through the closing tag. | |
| if s.starts_with("<tool_output") && !result.ends_with("</tool_output>") { | |
| // Detect strings that are actually wrapped in a <tool_output>...</tool_output> pair. | |
| let is_wrapped_tool_output = s.starts_with("<tool_output") | |
| && s.trim_end().ends_with("</tool_output>"); | |
| let closing_tag = "</tool_output>"; | |
| let closing_start = if is_wrapped_tool_output { | |
| s.rfind(closing_tag) | |
| } else { | |
| None | |
| }; | |
| // Walk backwards from an initial end position to find a valid char boundary. | |
| // For wrapped <tool_output> strings, avoid truncating inside the closing tag | |
| // by clamping `end` to the start of the final `</tool_output>`. | |
| let mut end = max_bytes; | |
| if let Some(close_start) = closing_start { | |
| if end > close_start { | |
| end = close_start; | |
| } | |
| } | |
| while end > 0 && !s.is_char_boundary(end) { | |
| end -= 1; | |
| } | |
| let mut result = format!("{}...", &s[..end]); | |
| // Re-close <tool_output> if we truncated a string that was originally wrapped. | |
| if is_wrapped_tool_output { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To reduce code duplication that this refactoring surfaces, you can add a helper method on
AppEventto get the event type string. This can then be used insse.rsandtypes.rsto avoid repeating the largematchstatement, as I'll suggest in other comments.References