use serde::{Deserialize, Serialize};
use super::cursor::Cursor;
use super::ids::{AgentId, RoomId};
use super::model::{AgentCard, MessageMeta, Room, RoomScope};
pub const PROTO_VER: u32 = 1;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
pub enum CommsRequest {
Hello {
agent: AgentId,
proto_ver: u32,
#[serde(default)]
remote: Option<String>,
#[serde(default)]
cwd: Option<std::path::PathBuf>,
},
Register {
card: AgentCard,
},
ListAgents {
#[serde(default)]
room: Option<RoomId>,
},
CreateRoom {
room: RoomId,
scope: RoomScope,
#[serde(default)]
title: Option<String>,
},
ListRooms {
#[serde(default)]
remote: Option<String>,
#[serde(default)]
cwd: Option<std::path::PathBuf>,
},
Join {
room: RoomId,
},
Leave {
room: RoomId,
},
Post {
room: RoomId,
subject: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
reply_to: Option<String>,
#[serde(default)]
scope: Vec<String>,
body: Vec<u8>,
},
History {
room: RoomId,
#[serde(default)]
cursor: Option<Cursor>,
#[serde(default)]
limit: Option<u32>,
},
GetBody {
message_id: String,
},
Inbox {
#[serde(default)]
remote: Option<String>,
#[serde(default)]
cwd: Option<std::path::PathBuf>,
#[serde(default)]
cursor: Option<Cursor>,
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
mark_read: bool,
},
AckInbox {
#[serde(default)]
message_ids: Vec<String>,
#[serde(default)]
room: Option<RoomId>,
#[serde(default)]
to_seq: Option<u64>,
},
Subscribe {
room: RoomId,
},
Unsubscribe {
sub: u64,
},
Ping,
Stop,
Status,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
pub enum CommsResponse {
Welcome {
proto_ver: u32,
daemon_version: String,
},
Ok,
Agents(Vec<super::model::AgentRecord>),
Room(Room),
Rooms(Vec<Room>),
Posted {
message_id: String,
},
History {
messages: Vec<SeqMeta>,
next_cursor: Option<Cursor>,
},
Inbox {
messages: Vec<SeqMeta>,
unread: u32,
next_cursor: Option<Cursor>,
},
Acked {
acked: u32,
cursors_advanced: Vec<(String, u64)>,
},
Body {
body: Option<Vec<u8>>,
},
Subscribed {
sub: u64,
},
Pong,
Status(StatusReport),
Error {
code: String,
message: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SeqMeta {
#[serde(default)]
pub seq: u64,
#[serde(flatten)]
pub meta: MessageMeta,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusReport {
pub pid: u32,
pub version: String,
pub proto_ver: u32,
pub uptime_secs: u64,
pub rooms: u32,
pub subscribers: u32,
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "notify", content = "data", rename_all = "snake_case")]
pub enum CommsNotification {
Message(MessageMeta),
Shutdown,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommsOut {
Response(CommsResponse),
Notification(CommsNotification),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_round_trips_through_msgpack() {
let req = CommsRequest::Post {
room: RoomId::parse("room-1").expect("room"),
subject: "hi".to_string(),
tags: vec!["t".to_string()],
reply_to: None,
scope: vec!["src/**".to_string()],
body: b"hello".to_vec(),
};
let bytes = rmp_serde::to_vec_named(&req).expect("encode");
let back: CommsRequest = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(req, back);
}
#[test]
fn request_is_json_rpc_shaped() {
let req = CommsRequest::Ping;
let json = serde_json::to_value(&req).expect("json");
assert_eq!(json["method"], "ping");
}
#[test]
fn out_frame_round_trips() {
let out = CommsOut::Notification(CommsNotification::Shutdown);
let bytes = rmp_serde::to_vec_named(&out).expect("encode");
let back: CommsOut = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(out, back);
}
fn sample_meta(id: &str) -> MessageMeta {
MessageMeta {
id: id.to_string(),
room: RoomId::parse("room-1").expect("room"),
from: AgentId::parse("agent-1").expect("agent"),
ts_micros: 7,
subject: "subj".to_string(),
tags: vec!["t".to_string()],
reply_to: None,
scope: vec!["src/**".to_string()],
body_len: 3,
body_sha: "abc".to_string(),
}
}
#[test]
fn seq_meta_round_trips_through_msgpack() {
let value = SeqMeta {
seq: 42,
meta: sample_meta("m-1"),
};
let bytes = rmp_serde::to_vec_named(&value).expect("encode");
let back: SeqMeta = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(value, back);
}
#[test]
fn seq_meta_decodes_legacy_bare_message_meta_with_seq_zero() {
let legacy = sample_meta("m-old");
let legacy_bytes = rmp_serde::to_vec_named(&legacy).expect("encode legacy MessageMeta");
let back: SeqMeta = rmp_serde::from_slice(&legacy_bytes).expect("decode legacy as SeqMeta");
assert_eq!(back.seq, 0, "missing seq defaults to 0 for legacy records");
assert_eq!(
back.meta, legacy,
"front-matter flattens into meta unchanged"
);
}
#[test]
fn legacy_history_response_decodes_against_seq_meta_element() {
#[derive(Serialize)]
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
enum LegacyResponse {
History {
messages: Vec<MessageMeta>,
next_cursor: Option<Cursor>,
},
}
let legacy = LegacyResponse::History {
messages: vec![sample_meta("m-a"), sample_meta("m-b")],
next_cursor: None,
};
let bytes = rmp_serde::to_vec_named(&legacy).expect("encode legacy History");
let back: CommsResponse = rmp_serde::from_slice(&bytes).expect("decode as W7 History");
match back {
CommsResponse::History { messages, .. } => {
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].seq, 0);
assert_eq!(messages[0].meta.id, "m-a");
assert_eq!(messages[1].meta.id, "m-b");
}
other => panic!("expected History, got {other:?}"),
}
}
}