use std::path::PathBuf;
use loopflow::chat::turns::{ChatRole, ChatTurn};
use loopflow::chat::types::{ConversationItem, Lifecycle};
use loopflow::lfd::http::dto::{CreateSessionRequestDto, SessionDto, UsageReportDto, WaveDto};
use loopflow::wave::state::MindState;
use loopflow::wave::wire::{
AttachRequest, AttachResponse, ContextResponse, PostDeltasRequest, ResidentDelta,
ResidentStateTo,
};
use serde_json::Value;
fn load_fixture(name: &str) -> Value {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/dto")
.join(name);
let bytes = std::fs::read(&path).unwrap_or_else(|err| {
panic!("read fixture {}: {err}", path.display());
});
serde_json::from_slice(&bytes).expect("parse fixture json")
}
fn load_top_fixture(name: &str) -> Value {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures")
.join(name);
let bytes = std::fs::read(&path).unwrap_or_else(|err| {
panic!("read fixture {}: {err}", path.display());
});
serde_json::from_slice(&bytes).expect("parse fixture json")
}
#[test]
fn wave_fixture_nests_repo_work() {
let wave: WaveDto =
serde_json::from_value(load_top_fixture("wave.json")).expect("wave fixture should parse");
assert_eq!(wave.id, "wave_abc123");
assert_eq!(wave.name, "engbot");
assert_eq!(wave.primary_flow, "build");
assert_eq!(wave.status, "running");
assert_eq!(wave.parent_wave_id.as_deref(), Some("wave_parent999"));
assert_eq!(wave.repos.len(), 1);
let repo = &wave.repos[0];
assert_eq!(repo.repo, "/home/user/project");
assert_eq!(repo.status, "running");
assert_eq!(repo.iteration, 3);
assert_eq!(
repo.local_worktree.as_deref(),
Some("/home/user/project/.claude/worktrees/engbot")
);
assert_eq!(repo.remote_branch.as_deref(), Some("engbot/build-3"));
assert_eq!(repo.open_pr_count, 1);
assert_eq!(repo.commits.len(), 1);
assert_eq!(repo.commits[0].sha, "abc1234");
}
#[test]
fn session_fixture_pins_palette_shape() {
let session = load_fixture("session.json");
assert_eq!(session["object"], "session");
let session: SessionDto =
serde_json::from_value(session).expect("session fixture should parse");
assert_eq!(session.step, "ship");
assert_eq!(session.agent, "codex");
assert_eq!(session.source, "palette");
assert_eq!(session.session_use, "palette");
assert_eq!(session.status, "running");
assert_eq!(session.run_id, None);
assert_eq!(session.parent_session_id, None);
assert!(session.argv.contains(&"-m".to_string()));
}
#[test]
fn chat_turn_fixture_pins_wave_chat_shape() {
let turn: ChatTurn =
serde_json::from_value(load_fixture("chat_turn.json")).expect("chat turn should parse");
assert_eq!(turn.id, "turn-3");
assert_eq!(turn.role, ChatRole::Assistant);
assert_eq!(turn.status, Lifecycle::Running);
assert_eq!(turn.from.as_deref(), Some("worker"));
assert_eq!(turn.items.len(), 6);
assert!(matches!(turn.items[0], ConversationItem::Command { .. }));
assert!(matches!(turn.items[1], ConversationItem::File { .. }));
assert!(matches!(turn.items[2], ConversationItem::Message { .. }));
assert!(matches!(turn.items[3], ConversationItem::Thought { .. }));
assert!(matches!(turn.items[4], ConversationItem::Tool { .. }));
assert!(matches!(
turn.items[5],
ConversationItem::Command {
status: Lifecycle::Interrupted,
output: None,
exit_code: None,
duration_ms: None,
..
}
));
let value = serde_json::to_value(&turn).expect("serialize chat turn");
assert!(value["items"][5]["output"].is_null());
assert!(value["items"][5]
.as_object()
.expect("object")
.contains_key("output"));
let mut without_from = load_fixture("chat_turn.json");
without_from.as_object_mut().expect("object").remove("from");
let turn: ChatTurn = serde_json::from_value(without_from).expect("absent from parses");
assert_eq!(turn.from, None);
}
#[test]
fn create_session_request_fixture_pins_required_fields() {
let request: CreateSessionRequestDto =
serde_json::from_value(load_fixture("create_session_request.json"))
.expect("create request fixture should parse");
assert_eq!(request.flow, "ship");
assert_eq!(request.worktree, "/tmp/repo.Desktop");
assert_eq!(request.agent, "codex");
}
#[test]
fn usage_report_fixture_pins_repo_provider_shape() {
let value = load_fixture("usage_report.json");
assert_eq!(value["object"], "usage_report");
let report: UsageReportDto =
serde_json::from_value(value).expect("usage report fixture should parse");
assert_eq!(report.by_repo_provider.len(), 3);
let loopflow_claude = report
.by_repo_provider
.iter()
.find(|row| row.repo.as_deref() == Some("/Users/jack/src/loopflow"))
.expect("loopflow/claude row");
assert_eq!(loopflow_claude.provider, "claude");
assert_eq!(loopflow_claude.input_tokens, 400);
assert_eq!(loopflow_claude.cache_read_tokens, 15);
let unattributed = report
.by_repo_provider
.iter()
.find(|row| row.repo.is_none())
.expect("null-repo row");
assert_eq!(unattributed.provider, "claude");
assert_eq!(report.by_wave_provider.len(), 1);
assert_eq!(
report.by_wave_provider[0].wave_id,
"lfdwave_01HNX7XYZ0AZ1B2C3D4E5F6G7H"
);
assert_eq!(report.by_provider.len(), 2);
let roundtrip = serde_json::to_value(&report).expect("serialize usage report");
assert!(roundtrip["by_repo_provider"]
.as_array()
.expect("array")
.iter()
.any(|row| row["repo"].is_null()));
}
#[test]
fn post_message_response_fixture_pins_wave_chat_reply() {
let value = load_fixture("post_message_response.json");
let turn: ChatTurn =
serde_json::from_value(value["turn"].clone()).expect("turn should parse as ChatTurn");
assert_eq!(turn.id, "turn-4");
assert_eq!(turn.role, ChatRole::User);
assert_eq!(turn.status, Lifecycle::Completed);
assert_eq!(turn.from, None);
assert_eq!(
serde_json::to_value(&turn).expect("serialize turn"),
value["turn"]
);
let state = value["state"].as_str().expect("state is a string");
assert_eq!(
state,
MindState::Turning {
turn_id: turn.id.clone(),
}
.name()
);
}
#[test]
fn channel_tagged_turn_fixture_pins_the_frame_shape() {
let value = load_fixture("channel_tagged_turn.json");
assert_eq!(value["channel"], "ship.148e0e02");
let turn: ChatTurn = serde_json::from_value(value.clone())
.expect("tagged frame parses as a ChatTurn (unknown key ignored)");
assert_eq!(turn.id, "turn-7");
assert_eq!(turn.from.as_deref(), Some("worker"));
let mut rebuilt = serde_json::to_value(&turn).expect("serialize turn");
rebuilt
.as_object_mut()
.expect("object")
.insert("channel".to_string(), value["channel"].clone());
assert_eq!(rebuilt, value);
}
#[test]
fn resident_deltas_fixture_round_trips_the_wire() {
let value = load_fixture("resident_deltas.json");
let request: PostDeltasRequest =
serde_json::from_value(value.clone()).expect("resident deltas fixture should parse");
assert_eq!(request.deltas.len(), 10);
assert!(matches!(
&request.deltas[0],
ResidentDelta::ThreadStarted { vendor, thread_id }
if vendor == "codex" && thread_id == "thread-0199a"
));
assert!(matches!(
&request.deltas[1],
ResidentDelta::TurnOpened { answers } if answers == &["msg-4", "msg-5"]
));
assert!(matches!(&request.deltas[2], ResidentDelta::TurnText { .. }));
assert!(matches!(&request.deltas[3], ResidentDelta::TurnItem { .. }));
assert!(matches!(
&request.deltas[4],
ResidentDelta::TurnUsage {
input_tokens: Some(1204),
output_tokens: Some(96),
cache_read_tokens: None,
}
));
assert!(matches!(
&request.deltas[5],
ResidentDelta::TurnFinished { status: Lifecycle::Completed, cost_usd: Some(cost) }
if (cost - 0.0125).abs() < f64::EPSILON
));
assert!(matches!(
&request.deltas[6],
ResidentDelta::TurnSteered { answers } if answers == &["msg-6"]
));
assert!(matches!(
&request.deltas[7],
ResidentDelta::MessagesRequeued { ids } if ids == &["msg-6"]
));
assert!(matches!(
&request.deltas[8],
ResidentDelta::MindState {
to: ResidentStateTo::Interrupting,
..
}
));
assert!(matches!(
&request.deltas[9],
ResidentDelta::MindState { to: ResidentStateTo::Failed, reason }
if reason.contains("codex_disconnected")
));
assert_eq!(
serde_json::to_value(&request).expect("serialize request"),
value
);
let missing = serde_json::from_value::<PostDeltasRequest>(serde_json::json!({
"deltas": [{ "kind": "turn_finished", "cost_usd": null }]
}));
assert!(missing.is_err(), "status is required on the wire");
}
#[test]
fn resident_door_fixture_round_trips_attach_and_context() {
let value = load_fixture("resident_door.json");
let attach: AttachRequest = serde_json::from_value(value["attach_request"].clone())
.expect("attach request should parse");
assert_eq!(attach.pid, 43210);
let attached: AttachResponse = serde_json::from_value(value["attach_response"].clone())
.expect("attach response should parse");
assert_eq!(attached.wave, "ship");
assert_eq!(attached.thread_id.as_deref(), Some("thread-0199a"));
let context: ContextResponse = serde_json::from_value(value["context_response"].clone())
.expect("context response should parse");
assert_eq!(context.thread_id.as_deref(), Some("thread-0199a"));
assert_eq!(context.in_flight.len(), 1);
assert_eq!(context.in_flight[0].flow, "implement");
assert_eq!(context.in_flight[0].task, "Wire the endpoint.");
assert_eq!(
serde_json::to_value(&attach).expect("serialize"),
value["attach_request"]
);
assert_eq!(
serde_json::to_value(&attached).expect("serialize"),
value["attach_response"]
);
assert_eq!(
serde_json::to_value(&context).expect("serialize"),
value["context_response"]
);
assert!(serde_json::from_value::<AttachRequest>(serde_json::json!({})).is_err());
assert!(
serde_json::from_value::<AttachResponse>(serde_json::json!({ "thread_id": null })).is_err()
);
assert!(
serde_json::from_value::<ContextResponse>(serde_json::json!({ "thread_id": null }))
.is_err(),
"in_flight is required"
);
let fresh: AttachResponse =
serde_json::from_value(serde_json::json!({ "wave": "ship", "thread_id": null }))
.expect("null thread_id parses");
assert_eq!(fresh.thread_id, None);
}
#[test]
fn wave_mind_states_fixture_pins_the_state_vocabulary() {
let value = load_fixture("wave_mind_states.json");
let fixture_names: Vec<&str> = value["states"]
.as_array()
.expect("states array")
.iter()
.map(|name| name.as_str().expect("state name is a string"))
.collect();
let variants = [
MindState::Idle,
MindState::Turning {
turn_id: "turn-1".to_string(),
},
MindState::Interrupting {
turn_id: "turn-1".to_string(),
},
MindState::Failed {
reason: "dead".to_string(),
},
];
let names: Vec<&str> = variants.iter().map(MindState::name).collect();
assert_eq!(fixture_names, names);
}