use std::path::PathBuf;
use mermaid_cli::app::Config;
use mermaid_cli::domain::{
Cmd, CompactionRecord, CompactionResult, CompactionTrigger, ContextUsageSnapshot, Msg,
PendingToolCall, PromptTokenBreakdown, SlashCmd, State, StatusKind, ToolCallId, ToolOutcome,
TurnId, TurnState, start_executing_tools, start_generating, update,
};
use mermaid_cli::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
use mermaid_cli::models::{ChatMessage, ChatMessageKind, MessageRole};
fn fresh() -> State {
State::new(
Config::default(),
PathBuf::from("/tmp/flow"),
"ollama/test".to_string(),
)
}
fn user_submit(state: State, text: &str) -> (State, Vec<Cmd>) {
update(
state,
Msg::SubmitPrompt {
text: text.to_string(),
attachment_ids: vec![],
},
)
}
#[test]
fn happy_path_turn_ends_idle_with_assistant_message() {
let (state, cmds) = user_submit(fresh(), "hello");
assert!(cmds.iter().any(|c| matches!(c, Cmd::CallModel { .. })));
let id = state.current_turn_id().expect("turn id");
let (state, _) = update(
state,
Msg::StreamText {
turn: id,
chunk: "hello back".to_string(),
},
);
let (state, cmds) = update(
state,
Msg::StreamDone {
turn: id,
usage: None,
thinking_signature: None,
},
);
assert!(matches!(state.turn, TurnState::Idle));
assert_eq!(state.session.messages().len(), 2);
let last = state.session.messages().last().unwrap();
assert_eq!(last.role, MessageRole::Assistant);
assert_eq!(last.content, "hello back");
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
}
#[test]
fn stale_stream_chunks_cannot_corrupt_current_turn() {
let (state, _) = user_submit(fresh(), "first");
let turn_a = state.current_turn_id().unwrap();
let (state, _) = update(
state,
Msg::StreamText {
turn: turn_a,
chunk: "from A".to_string(),
},
);
let (state, _) = update(state, Msg::CancelTurn);
let (state, _) = update(
state,
Msg::StreamDone {
turn: turn_a,
usage: None,
thinking_signature: None,
},
);
assert_eq!(
state
.session
.messages()
.iter()
.filter(|m| m.content.contains("from A"))
.count(),
0,
"stale content from cancelled turn must not reach committed history"
);
}
#[test]
fn stream_text_from_prior_turn_is_ignored() {
let mut state = fresh();
state.turn = start_generating(TurnId(10));
let (state, _) = update(
state,
Msg::StreamText {
turn: TurnId(9), chunk: "stale".to_string(),
},
);
match &state.turn {
TurnState::Generating { partial_text, .. } => assert!(partial_text.is_empty()),
_ => panic!("should still be Generating"),
}
}
#[test]
fn tool_outcomes_must_all_land_before_followup_call() {
let mut state = fresh();
let calls = vec![
PendingToolCall {
call_id: ToolCallId(1),
source: ModelToolCall {
id: Some("a".to_string()),
function: FunctionCall {
name: "read_file".to_string(),
arguments: serde_json::json!({}),
},
},
},
PendingToolCall {
call_id: ToolCallId(2),
source: ModelToolCall {
id: Some("b".to_string()),
function: FunctionCall {
name: "write_file".to_string(),
arguments: serde_json::json!({}),
},
},
},
];
state.turn = start_executing_tools(TurnId(1), calls);
state
.session
.append(mermaid_cli::models::ChatMessage::assistant(
"ok let me call tools",
));
let (state, cmds) = update(
state,
Msg::ToolFinished {
turn: TurnId(1),
call_id: ToolCallId(1),
outcome: ToolOutcome::success("first done", "first done", 0.1),
},
);
assert!(matches!(state.turn, TurnState::ExecutingTools { .. }));
assert!(cmds.is_empty(), "no follow-up until all tools finish");
let (state, cmds) = update(
state,
Msg::ToolFinished {
turn: TurnId(1),
call_id: ToolCallId(2),
outcome: ToolOutcome::success("second done", "second done", 0.1),
},
);
assert!(matches!(state.turn, TurnState::Generating { .. }));
assert!(cmds.iter().any(|c| matches!(c, Cmd::CallModel { .. })));
let tool_msgs = state
.session
.messages()
.iter()
.filter(|m| m.role == MessageRole::Tool)
.count();
assert_eq!(tool_msgs, 2);
}
#[test]
fn cancelled_tool_produces_placeholder_in_history() {
let mut state = fresh();
let call = PendingToolCall {
call_id: ToolCallId(1),
source: ModelToolCall {
id: None,
function: FunctionCall {
name: "read_file".to_string(),
arguments: serde_json::json!({}),
},
},
};
state.turn = start_executing_tools(TurnId(3), vec![call]);
state
.session
.append(mermaid_cli::models::ChatMessage::assistant("calling tool"));
let (state, _) = update(
state,
Msg::ToolFinished {
turn: TurnId(3),
call_id: ToolCallId(1),
outcome: ToolOutcome::cancelled(),
},
);
let last = state.session.messages().last().unwrap();
assert_eq!(last.role, MessageRole::Tool);
assert!(last.content.contains("cancelled"));
}
#[test]
fn cancel_emits_scope_cancel_and_transitions_cancelling() {
let mut state = fresh();
state.turn = start_generating(TurnId(7));
let (state, cmds) = update(state, Msg::CancelTurn);
assert!(
matches!(state.turn, TurnState::Cancelling { id: TurnId(7), .. }),
"active turn must be in Cancelling"
);
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::CancelScope(TurnId(7)))),
"reducer must emit Cmd::CancelScope so the runner tears down"
);
}
#[test]
fn cancel_on_idle_is_noop() {
let (state, cmds) = update(fresh(), Msg::CancelTurn);
assert!(matches!(state.turn, TurnState::Idle));
assert!(cmds.is_empty());
}
#[test]
fn double_cancel_does_not_emit_a_second_cancel_scope() {
let mut state = fresh();
state.turn = TurnState::Cancelling {
id: TurnId(1),
since: std::time::SystemTime::now(),
};
let (_state, cmds) = update(state, Msg::CancelTurn);
assert!(cmds.iter().all(|c| !matches!(c, Cmd::CancelScope(_))));
}
#[test]
fn cancel_then_turn_cancelled_returns_to_idle() {
let (state, _) = user_submit(fresh(), "will be cancelled");
let id = state.current_turn_id().expect("turn in flight");
let (state, cmds) = update(state, Msg::CancelTurn);
assert!(matches!(state.turn, TurnState::Cancelling { .. }));
assert!(cmds.iter().any(|c| matches!(c, Cmd::CancelScope(_))));
let (state, _) = update(state, Msg::TurnCancelled(id));
assert!(
matches!(state.turn, TurnState::Idle),
"TurnCancelled should clear Cancelling; got {:?}",
state.turn,
);
}
#[test]
fn stale_turn_cancelled_does_not_mutate_state() {
let (state, _) = user_submit(fresh(), "active turn");
let live = state.current_turn_id().unwrap();
let stale = TurnId(live.0 + 100);
let (state, cmds) = update(state, Msg::TurnCancelled(stale));
assert!(matches!(state.turn, TurnState::Generating { .. }));
assert!(cmds.is_empty());
}
#[test]
fn upstream_error_ends_turn_exactly_once() {
let mut state = fresh();
state.turn = start_generating(TurnId(4));
let err = mermaid_cli::models::UserFacingError {
summary: "Server error".to_string(),
message: "500 internal".to_string(),
suggestion: "try again".to_string(),
category: mermaid_cli::models::ErrorCategory::Temporary,
recoverable: true,
};
let (state, _) = update(
state,
Msg::UpstreamError {
turn: TurnId(4),
error: err,
},
);
assert!(matches!(state.turn, TurnState::Idle));
assert_eq!(state.session.messages().len(), 1);
assert!(
state.status.is_none(),
"upstream errors must not set a status banner; chat already shows them"
);
}
#[test]
fn upstream_error_from_stale_turn_is_dropped() {
let mut state = fresh();
state.turn = start_generating(TurnId(8));
let err = mermaid_cli::models::UserFacingError {
summary: "late".to_string(),
message: "".to_string(),
suggestion: "".to_string(),
category: mermaid_cli::models::ErrorCategory::Temporary,
recoverable: false,
};
let (state, _) = update(
state,
Msg::UpstreamError {
turn: TurnId(7), error: err,
},
);
assert!(matches!(state.turn, TurnState::Generating { .. }));
assert!(state.session.messages().is_empty());
}
#[test]
fn slash_clear_requires_confirmation_before_wiping() {
let mut state = fresh();
state
.session
.append(mermaid_cli::models::ChatMessage::user("priceless"));
let (state, _) = update(state, Msg::Slash(SlashCmd::Clear));
assert!(state.confirm.is_some());
assert_eq!(state.session.messages().len(), 1);
let (state, _) = update(state, Msg::ConfirmAccepted);
assert!(state.session.messages().is_empty());
assert!(state.confirm.is_none());
}
#[test]
fn slash_save_emits_save_conversation() {
let (_state, cmds) = update(fresh(), Msg::Slash(SlashCmd::Save(None)));
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
}
#[test]
fn slash_compact_emits_compaction_command() {
let mut state = fresh();
state.session.append(ChatMessage::user("old prompt"));
state.session.append(ChatMessage::assistant("old answer"));
state.session.append(ChatMessage::user("new prompt"));
let (state, cmds) = update(
state,
Msg::Slash(SlashCmd::Compact(Some("focus on tests".to_string()))),
);
assert!(matches!(state.turn, TurnState::Compacting { .. }));
assert!(state.status.as_ref().unwrap().text.contains("Compacting"));
assert!(cmds.iter().any(|cmd| {
matches!(
cmd,
Cmd::CompactConversation { request, .. }
if request.instructions.as_deref() == Some("focus on tests")
)
}));
}
#[test]
fn compaction_finished_replaces_history_and_archives_head() {
let mut state = fresh();
state.session.append(ChatMessage::user("old prompt"));
state.session.append(ChatMessage::assistant("old answer"));
state.session.append(ChatMessage::user("new prompt"));
let (state, cmds) = update(state, Msg::Slash(SlashCmd::Compact(None)));
let turn = state.turn.id().expect("compaction turn");
assert!(
cmds.iter()
.any(|cmd| matches!(cmd, Cmd::CompactConversation { .. }))
);
let before = ContextUsageSnapshot::from_estimate(
PromptTokenBreakdown {
system_tokens: 10,
instructions_tokens: 0,
message_tokens: 90,
tool_schema_tokens: 0,
image_count: 0,
message_count: 3,
tool_count: 0,
},
Some(1_000),
);
let after = ContextUsageSnapshot::from_estimate(
PromptTokenBreakdown {
system_tokens: 10,
instructions_tokens: 0,
message_tokens: 20,
tool_schema_tokens: 0,
image_count: 0,
message_count: 3,
tool_count: 0,
},
Some(1_000),
);
let mut checkpoint = ChatMessage::user("MERMAID CONTEXT CHECKPOINT\n## Goal\n- continue");
checkpoint.kind = ChatMessageKind::ContextCheckpoint;
let replacement = vec![
checkpoint,
ChatMessage::assistant("Context compacted: 100 -> 30 tokens."),
ChatMessage::user("new prompt"),
];
let result = CompactionResult {
record: CompactionRecord {
id: "compact_test".to_string(),
trigger: CompactionTrigger::Manual,
created_at: chrono::Local::now(),
before_tokens: 100,
after_tokens: 30,
archived_message_count: 2,
preserved_message_count: 1,
summary_tokens: 10,
duration_secs: 0.5,
focus: None,
archive_path: None,
},
replacement_messages: replacement,
archived_messages: vec![
ChatMessage::user("old prompt"),
ChatMessage::assistant("old answer"),
],
before_snapshot: before,
after_snapshot: after,
usage: None,
};
let (state, cmds) = update(state, Msg::CompactionFinished { turn, result });
assert!(matches!(state.turn, TurnState::Idle));
assert_eq!(state.session.messages().len(), 3);
assert_eq!(
state.session.messages()[0].kind,
ChatMessageKind::ContextCheckpoint
);
assert_eq!(state.session.conversation.compactions.len(), 1);
assert!(
cmds.iter()
.any(|cmd| matches!(cmd, Cmd::SaveConversation(_)))
);
assert!(
cmds.iter()
.any(|cmd| matches!(cmd, Cmd::SaveCompactionArchive(_)))
);
}
#[test]
fn slash_unknown_sets_warn_status() {
let (state, cmds) = update(fresh(), Msg::Slash(SlashCmd::Unknown("nope".to_string())));
assert!(state.status.is_some());
assert!(matches!(
state.status.as_ref().unwrap().kind,
StatusKind::Warn
));
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::DismissStatusAfter { .. }))
);
}
#[test]
fn quit_saves_and_sets_exit() {
let (state, cmds) = update(fresh(), Msg::Quit);
assert!(state.should_exit);
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
}
#[test]
fn stream_tool_call_buffers_then_stream_done_transitions_to_executing_tools() {
let (state, _) = user_submit(fresh(), "do a thing");
let id = state.current_turn_id().unwrap();
let (state, _) = update(
state,
Msg::StreamToolCall {
turn: id,
call: ModelToolCall {
id: Some("call_1".to_string()),
function: FunctionCall {
name: "read_file".to_string(),
arguments: serde_json::json!({"path": "x"}),
},
},
},
);
let (state, cmds) = update(
state,
Msg::StreamDone {
turn: id,
usage: None,
thinking_signature: None,
},
);
assert!(
matches!(state.turn, TurnState::ExecutingTools { .. }),
"expected ExecutingTools; got {:?}",
state.turn
);
assert!(
cmds.iter().any(|c| matches!(c, Cmd::ExecuteTool { .. })),
"expected Cmd::ExecuteTool; got {:?}",
cmds.iter().map(|c| c.tag()).collect::<Vec<_>>()
);
}
#[test]
fn tool_progress_artifact_routes_image_to_assistant_message() {
use mermaid_cli::providers::ProgressEvent;
let (state, _) = user_submit(fresh(), "take a screenshot");
let id = state.current_turn_id().unwrap();
let mut state = state;
state.session.append(mermaid_cli::models::ChatMessage {
role: MessageRole::Assistant,
content: String::new(),
timestamp: chrono::Local::now(),
kind: mermaid_cli::models::ChatMessageKind::Normal,
metadata: None,
actions: vec![],
thinking: None,
images: None,
tool_calls: None,
tool_call_id: None,
tool_name: None,
thinking_signature: None,
});
state.turn = start_executing_tools(
id,
vec![PendingToolCall {
call_id: ToolCallId(1),
source: ModelToolCall {
id: Some("c1".to_string()),
function: FunctionCall {
name: "screenshot".to_string(),
arguments: serde_json::json!({}),
},
},
}],
);
let data = vec![0x89, 0x50, 0x4E, 0x47]; let (state, _) = update(
state,
Msg::ToolProgress {
turn: id,
call_id: ToolCallId(1),
event: ProgressEvent::Artifact {
mime: "image/png".to_string(),
data: data.clone(),
caption: Some("preview".to_string()),
},
},
);
let last = state.session.messages().last().expect("last msg");
assert_eq!(last.role, MessageRole::Assistant);
let imgs = last.images.as_ref().expect("images attached");
assert_eq!(imgs.len(), 1, "one artifact appended");
use base64::{Engine as _, engine::general_purpose};
let decoded = general_purpose::STANDARD.decode(&imgs[0]).unwrap();
assert_eq!(decoded, data);
}
#[test]
fn configured_mcp_servers_seed_state_and_ready_updates() {
use mermaid_cli::app::{Config as AppConfig, McpServerConfig};
use mermaid_cli::domain::{McpServerStatus, McpToolSpec};
let mut cfg = AppConfig::default();
cfg.mcp_servers.insert(
"context7".to_string(),
McpServerConfig {
command: "npx".to_string(),
args: vec!["-y".to_string(), "@upstash/context7-mcp".to_string()],
env: std::collections::HashMap::new(),
},
);
let state = State::new(cfg, PathBuf::from("/tmp/p"), "ollama/test".to_string());
let entry = state
.mcp
.servers
.get("context7")
.expect("configured server must be seeded");
assert_eq!(entry.status, McpServerStatus::Starting);
assert!(entry.tools.is_empty());
let (state, _) = update(
state,
Msg::McpServerReady {
name: "context7".to_string(),
tools: vec![McpToolSpec {
name: "resolve-library-id".to_string(),
description: "Resolve a library name".to_string(),
input_schema: serde_json::json!({"type": "object"}),
}],
},
);
let entry = &state.mcp.servers["context7"];
assert_eq!(entry.status, McpServerStatus::Ready);
assert_eq!(entry.tools.len(), 1);
assert_eq!(entry.tools[0].name, "resolve-library-id");
}
#[test]
fn tick_never_mutates_visible_state() {
let before = fresh();
let before_msg_count = before.session.messages().len();
let (after, cmds) = update(before, Msg::Tick);
assert!(cmds.is_empty());
assert_eq!(after.session.messages().len(), before_msg_count);
assert!(matches!(after.turn, TurnState::Idle));
}