use serde::{Deserialize, Serialize};
use crate::chat::turns::BodyProvenance;
use crate::chat::types::{ConversationItem, Lifecycle};
use crate::controller::wave::journal::{DiscordMessageSource, MessageOp};
use crate::work::project::ProjectObservation;
use crate::work::task::TaskObservation;
pub const RESIDENT_TOKEN_HEADER: &str = "x-lf-resident-token";
pub const RESIDENT_TOKEN_ENV: &str = "LF_WAVE_RESIDENT_TOKEN";
pub const RESIDENT_TOKEN_FILE: &str = ".wave-resident-token";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResidentDelta {
TurnOpened {
answers: Vec<String>,
body: Option<BodyProvenance>,
},
TurnText { text: String },
ChatReply { message_id: String, text: String },
TurnItem { item: ConversationItem },
TurnFinished {
status: Lifecycle,
reason: Option<String>,
},
BodySessionUpdated { body_id: String, session_id: String },
LoopState { to: ResidentStateTo, reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResidentStateTo {
Interrupting,
Failed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PostDeltasRequest {
pub deltas: Vec<ResidentDelta>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PostDeltasResponse {
pub accepted: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AttachRequest {
pub pid: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AttachResponse {
pub wave: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContextResponse {
pub provider_session: Option<ProviderSessionRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderSessionRef {
pub harness: String,
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InboxFrame {
Message {
id: String,
op: MessageOp,
text: String,
source: Option<DiscordMessageSource>,
},
Task {
observation: TaskObservation,
},
Project {
observation: ProjectObservation,
},
Promotion {
parent_wave_id: crate::id::WaveId,
parent: String,
},
Interrupt,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resident_delta_round_trips_every_variant() {
let deltas = vec![
ResidentDelta::TurnOpened {
body: None,
answers: vec!["msg-1".into(), "msg-2".into()],
},
ResidentDelta::TurnText {
text: "thinking".into(),
},
ResidentDelta::ChatReply {
message_id: "msg-2".into(),
text: "answer".into(),
},
ResidentDelta::TurnItem {
item: ConversationItem::Tool {
id: "t-1".into(),
name: "Bash".into(),
status: Lifecycle::Completed,
input: None,
output: Some("ok".into()),
},
},
ResidentDelta::TurnFinished {
status: Lifecycle::Completed,
reason: None,
},
ResidentDelta::LoopState {
to: ResidentStateTo::Failed,
reason: "harness disconnected".into(),
},
];
for delta in deltas {
let value = serde_json::to_value(&delta).expect("serialize");
let decoded: ResidentDelta = serde_json::from_value(value).expect("deserialize");
assert_eq!(decoded, delta);
}
}
#[test]
fn resident_delta_fixture_round_trips() {
let value: serde_json::Value = serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/fixtures/dto/resident_deltas.json"
)))
.expect("resident delta fixture JSON");
let request: PostDeltasRequest =
serde_json::from_value(value.clone()).expect("resident delta fixture decodes");
assert_eq!(serde_json::to_value(request).unwrap(), value);
}
#[test]
fn resident_door_fixture_contains_only_the_current_contract() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/fixtures/dto/resident_door.json"
)))
.unwrap();
let request: AttachRequest =
serde_json::from_value(fixture["attach_request"].clone()).unwrap();
let response: AttachResponse =
serde_json::from_value(fixture["attach_response"].clone()).unwrap();
let context: ContextResponse =
serde_json::from_value(fixture["context_response"].clone()).unwrap();
assert_eq!(
serde_json::json!({
"attach_request": request, "attach_response": response, "context_response": context,
}),
fixture
);
}
#[test]
fn absent_required_fields_are_parse_errors() {
for bad in [
serde_json::json!({ "kind": "turn_opened" }),
serde_json::json!({ "kind": "turn_finished" }),
serde_json::json!({ "kind": "turn_text" }),
serde_json::json!({ "kind": "chat_reply" }),
serde_json::json!({ "kind": "loop_state", "to": "failed" }),
serde_json::json!({ "kind": "messages_requeued" }),
] {
assert!(
serde_json::from_value::<ResidentDelta>(bad.clone()).is_err(),
"must reject {bad}"
);
}
assert!(serde_json::from_value::<InboxFrame>(serde_json::json!({
"kind": "message",
"text": "hi"
}))
.is_err());
let frame: InboxFrame = serde_json::from_value(serde_json::json!({
"kind": "message",
"id": "msg-1",
"op": "message",
"text": "hi"
}))
.expect("message frame parses");
assert!(matches!(frame, InboxFrame::Message { .. }));
}
}