use serde_json::{Value, json};
pub const PARSE_ERROR: i64 = -32700;
pub const INVALID_REQUEST: i64 = -32600;
pub const METHOD_NOT_FOUND: i64 = -32601;
pub const INVALID_PARAMS: i64 = -32602;
pub const INTERNAL_ERROR: i64 = -32603;
pub const INITIALIZE: &str = "initialize";
pub const SESSION_NEW: &str = "session/new";
pub const SESSION_LOAD: &str = "session/load";
pub const SESSION_LIST: &str = "session/list";
pub const SESSION_RESUME: &str = "session/resume";
pub const SESSION_SET_CONFIG_OPTION: &str = "session/set_config_option";
pub const SESSION_CLOSE: &str = "session/close";
pub const SESSION_DELETE: &str = "session/delete";
pub const SESSION_PROMPT: &str = "session/prompt";
pub const SESSION_SET_MODEL: &str = "_opencrabs/set_model";
pub const SESSION_SET_MODEL_LEGACY: &str = "session/set_model";
pub const SESSION_SET_MODE: &str = "session/set_mode";
pub const SESSION_CANCEL: &str = "session/cancel";
pub const SESSION_COMPACT: &str = "_opencrabs/compact";
pub const SESSION_COMPACT_LEGACY: &str = "session/compact";
pub const SESSION_STEER: &str = "_session/steer";
pub const SESSION_STEER_LEGACY: &str = "session/steer";
pub const EXTENSION_METHODS: &[&str] = &[SESSION_SET_MODEL, SESSION_COMPACT, SESSION_STEER];
pub const SESSION_UPDATE: &str = "session/update";
pub const SESSION_REQUEST_PERMISSION: &str = "session/request_permission";
#[derive(Debug)]
pub enum ClientMessage {
Request {
id: Value,
method: String,
params: Value,
},
Notification {
method: String,
params: Value,
},
Response {
id: u64,
result: Result<Value, RpcError>,
},
}
#[derive(Debug, Clone)]
pub struct RpcError {
pub code: i64,
pub message: String,
}
pub fn parse_line(line: &str) -> Result<ClientMessage, (Value, RpcError)> {
let value: Value = serde_json::from_str(line).map_err(|e| {
(
Value::Null,
RpcError {
code: PARSE_ERROR,
message: format!("invalid JSON: {e}"),
},
)
})?;
let method = value.get("method").and_then(Value::as_str);
let id = value.get("id").cloned();
match (method, id) {
(Some(m), Some(id)) => Ok(ClientMessage::Request {
id,
method: m.to_string(),
params: value.get("params").cloned().unwrap_or(Value::Null),
}),
(Some(m), None) => Ok(ClientMessage::Notification {
method: m.to_string(),
params: value.get("params").cloned().unwrap_or(Value::Null),
}),
(None, Some(id)) => {
let Some(n) = id.as_u64() else {
return Err((
id,
RpcError {
code: INVALID_REQUEST,
message: "response id is not a server-issued id".into(),
},
));
};
let result = if let Some(err) = value.get("error") {
Err(RpcError {
code: err
.get("code")
.and_then(Value::as_i64)
.unwrap_or(INTERNAL_ERROR),
message: err
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown error")
.to_string(),
})
} else {
Ok(value.get("result").cloned().unwrap_or(Value::Null))
};
Ok(ClientMessage::Response { id: n, result })
}
(None, None) => Err((
Value::Null,
RpcError {
code: INVALID_REQUEST,
message: "message has neither method nor id".into(),
},
)),
}
}
pub fn result_response(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
pub fn error_response(id: Value, code: i64, message: impl Into<String>) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message.into() },
})
}
pub fn notification(method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "method": method, "params": params })
}
pub fn request_frame(id: u64, method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
}
pub fn session_update(session_id: &str, update: Value) -> Value {
notification(
SESSION_UPDATE,
json!({ "sessionId": session_id, "update": update }),
)
}
pub fn session_response(
session_id: &str,
models: Value,
modes: Value,
config_options: Value,
echo_session_id: bool,
) -> Value {
let mut response = json!({
"models": models,
"modes": modes,
"configOptions": config_options,
});
if echo_session_id {
response["sessionId"] = Value::from(session_id);
}
response
}
pub fn plan_update(entries: Vec<Value>) -> Value {
json!({
"sessionUpdate": "plan",
"entries": entries,
})
}
pub fn list_sessions_payload(sessions: Vec<Value>, next_cursor: Option<String>) -> Value {
let mut response = json!({ "sessions": sessions });
if let Some(cursor) = next_cursor {
response["nextCursor"] = Value::from(cursor);
}
response
}
pub fn session_info(
session_id: &str,
cwd: &str,
title: Option<&str>,
updated_at: Option<&str>,
) -> Value {
let mut info = json!({ "sessionId": session_id, "cwd": cwd });
if let Some(title) = title {
info["title"] = Value::from(title);
}
if let Some(updated_at) = updated_at {
info["updatedAt"] = Value::from(updated_at);
}
info
}
pub fn config_options_response(config_options: Value) -> Value {
json!({ "configOptions": config_options })
}
pub fn text_chunk(kind: &str, text: &str) -> Value {
json!({
"sessionUpdate": kind,
"content": { "type": "text", "text": text },
})
}
pub fn usage_update(used: u64, size: u64) -> Value {
json!({
"sessionUpdate": "usage_update",
"used": used,
"size": size,
})
}
pub fn replay_updates(messages: &[crate::db::models::Message]) -> Vec<Value> {
use crate::tui::app::reasoning_split::{BLOCKED_LABEL, Segment, split_segments};
let mut out = Vec::new();
for m in messages {
match m.role.as_str() {
"user" => {
if !m.content.trim().is_empty() {
out.push(text_chunk("user_message_chunk", &m.content));
}
}
"assistant" => {
if let Some(thinking) = &m.thinking
&& !thinking.trim().is_empty()
{
out.push(text_chunk("agent_thought_chunk", thinking));
}
for segment in split_segments(&m.content) {
match segment {
Segment::Reasoning(t) => {
out.push(text_chunk("agent_thought_chunk", &t));
}
Segment::Blocked(b) => out.push(text_chunk(
"agent_thought_chunk",
&format!("{BLOCKED_LABEL}\n\n{b}"),
)),
Segment::Text(t) => out.push(text_chunk("agent_message_chunk", &t)),
}
}
}
_ => {}
}
}
out
}
pub fn replay_usage(messages: &[crate::db::models::Message]) -> Option<i64> {
messages
.iter()
.rev()
.find(|m| m.role == "assistant")
.and_then(|m| m.input_tokens)
}
pub fn initialize_result() -> Value {
json!({
"protocolVersion": 1,
"authMethods": [],
"agentCapabilities": {
"loadSession": true,
"sessionCapabilities": {
"list": {},
"resume": {},
"close": {},
"delete": {},
},
"promptCapabilities": { "text": true, "image": true, "embeddedContext": false },
},
"agentInfo": {
"name": "opencrabs",
"version": env!("CARGO_PKG_VERSION"),
},
"_meta": {
"extensions": EXTENSION_METHODS,
},
})
}
pub fn prompt_text(params: &Value) -> Option<String> {
let blocks = params.get("prompt")?.as_array()?;
let mut parts = Vec::new();
let mut images = 0usize;
for block in blocks {
match block.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(t) = block.get("text").and_then(Value::as_str) {
parts.push(t.to_string());
}
}
Some("image") => {
if let Some(path) = decode_image_block(block) {
parts.push(format!("<<IMG:{path}>>"));
images += 1;
}
}
Some("resource_link") => {
if let Some(uri) = block.get("uri").and_then(Value::as_str) {
parts.push(format!("Attached file (read from disk): {uri:?}"));
}
}
Some("resource") => {
if let Some(uri) = block
.get("resource")
.and_then(|r| r.get("uri"))
.and_then(Value::as_str)
{
parts.push(format!("Attached file (read from disk): {uri:?}"));
}
}
_ => {}
}
}
if images > 0 {
parts.push(
"IMPORTANT: Image attachment(s) present. Call analyze_image for EACH <<IMG:path>> marker separately. Do not skip any image."
.to_string(),
);
}
let text = parts.join("\n");
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
fn decode_image_block(block: &Value) -> Option<String> {
use base64::Engine as _;
let data = block
.get("data")
.or_else(|| block.get("source").and_then(|s| s.get("data")))
.and_then(Value::as_str)?;
let mime = block
.get("mimeType")
.or_else(|| block.get("source").and_then(|s| s.get("mimeType")))
.and_then(Value::as_str)
.unwrap_or("image/png");
let ext = match mime {
"image/jpeg" | "image/jpg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "png",
};
let bytes = base64::engine::general_purpose::STANDARD
.decode(data.trim())
.ok()?;
let dir = crate::channels::channel_attachments_dir().join("acp");
std::fs::create_dir_all(&dir).ok()?;
let name = format!(
"acp-img-{}.{ext}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos()
);
let path = dir.join(name);
std::fs::write(&path, bytes).ok()?;
Some(path.to_string_lossy().into_owned())
}
pub fn tool_kind(tool_name: &str) -> &'static str {
match tool_name {
"read_file" | "load_brain_file" => "read",
"edit_file" | "write_file" | "hashline_edit" | "write_opencrabs_file" => "edit",
"grep" | "glob" | "memory_search" | "kb_search" | "web_search" | "exa_search"
| "brave_search" | "session_search" | "channel_search" => "search",
"bash" | "execute_code" | "slash_command" => "execute",
"http_request" | "web_scrape" => "fetch",
"plan" | "bankai_task" | "bankai_prime" | "session_context" => "think",
_ => "other",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AcpMode {
#[default]
Supervised,
AutoAcceptEdits,
Auto,
FullAccess,
Plan,
}
impl AcpMode {
pub fn parse(id: &str) -> Option<Self> {
match id {
"supervised" => Some(Self::Supervised),
"auto-accept-edits" => Some(Self::AutoAcceptEdits),
"auto" => Some(Self::Auto),
"full-access" => Some(Self::FullAccess),
"plan" => Some(Self::Plan),
_ => None,
}
}
pub fn id(self) -> &'static str {
match self {
Self::Supervised => "supervised",
Self::AutoAcceptEdits => "auto-accept-edits",
Self::Auto => "auto",
Self::FullAccess => "full-access",
Self::Plan => "plan",
}
}
}
pub fn modes_payload(current: AcpMode) -> Value {
json!({
"availableModes": [
{ "id": "supervised", "name": "Supervised" },
{ "id": "auto-accept-edits", "name": "Auto-accept edits" },
{ "id": "auto", "name": "Auto" },
{ "id": "full-access", "name": "Full access" },
{ "id": "plan", "name": "Plan (read-only)" },
],
"currentModeId": current.id(),
})
}
pub fn permission_options() -> Value {
json!([
{ "optionId": "allow-once", "name": "Allow once", "kind": "allow_once" },
{ "optionId": "allow-always", "name": "Always allow", "kind": "allow_always" },
{ "optionId": "reject-once", "name": "Reject", "kind": "reject_once" },
])
}
pub fn permission_outcome(result: &Value) -> (bool, bool) {
let outcome = result.get("outcome").unwrap_or(result);
let selected = outcome.get("outcome").and_then(Value::as_str);
if selected != Some("selected") {
return (false, false);
}
match outcome.get("optionId").and_then(Value::as_str) {
Some("allow-always") | Some("allow_always") => (true, true),
Some("allow-once") | Some("allow_once") | Some("allow") => (true, false),
_ => (false, false),
}
}
#[cfg(test)]
mod tests {
use super::*;
const PNG_1PX: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
#[test]
fn image_blocks_become_markers_on_disk() {
let params = json!({
"prompt": [
{ "type": "text", "text": "lihat ini" },
{ "type": "image", "data": PNG_1PX, "mimeType": "image/png" }
]
});
let text = prompt_text(¶ms).expect("turn with image keeps text");
assert!(text.contains("lihat ini"));
let start = text.find("<<IMG:").expect("IMG marker present");
let end = text[start..].find(">>").expect("marker closed") + start;
let path = &text[start + 6..end];
assert!(
std::path::Path::new(path).exists(),
"decoded image written to {path}"
);
assert!(
text.contains("analyze_image"),
"analysis instruction present"
);
}
#[test]
fn nested_source_image_shape_also_decodes() {
let params = json!({
"prompt": [
{ "type": "image", "source": { "data": PNG_1PX, "mimeType": "image/png" } }
]
});
let text = prompt_text(¶ms).expect("image-only turn yields marker text");
assert!(text.contains("<<IMG:"));
}
#[test]
fn text_only_prompts_get_no_image_instruction() {
let params = json!({ "prompt": [{ "type": "text", "text": "halo" }] });
let text = prompt_text(¶ms).expect("plain text");
assert!(!text.contains("<<IMG:"));
assert!(!text.contains("analyze_image"));
}
}