use swink_agent::{
AssistantMessageEvent, ContentBlock, Cost, StopReason, StreamOptions, StreamTransport, Usage,
accumulate_message,
};
#[test]
fn accumulate_text_and_tool_call() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::TextStart { content_index: 0 },
AssistantMessageEvent::TextDelta {
content_index: 0,
delta: "Hello".into(),
},
AssistantMessageEvent::TextDelta {
content_index: 0,
delta: " world".into(),
},
AssistantMessageEvent::TextEnd { content_index: 0 },
AssistantMessageEvent::ToolCallStart {
content_index: 1,
id: "tc_1".into(),
name: "search".into(),
},
AssistantMessageEvent::ToolCallDelta {
content_index: 1,
delta: r#"{"q":"#.into(),
},
AssistantMessageEvent::ToolCallDelta {
content_index: 1,
delta: r#""rust"}"#.into(),
},
AssistantMessageEvent::ToolCallEnd { content_index: 1 },
AssistantMessageEvent::Done {
stop_reason: StopReason::ToolUse,
usage: Usage {
input: 100,
output: 50,
cache_read: 0,
cache_write: 0,
total: 150,
..Default::default()
},
cost: Cost {
input: 0.01,
output: 0.02,
cache_read: 0.0,
cache_write: 0.0,
total: 0.03,
..Default::default()
},
},
];
let msg = accumulate_message(events, "anthropic", "claude-sonnet-4-6").unwrap();
assert_eq!(msg.content.len(), 2);
assert_eq!(msg.provider, "anthropic");
assert_eq!(msg.model_id, "claude-sonnet-4-6");
assert_eq!(msg.stop_reason, StopReason::ToolUse);
assert_eq!(msg.usage.input, 100);
assert_eq!(msg.usage.output, 50);
assert_eq!(msg.usage.total, 150);
assert!((msg.cost.total - 0.03).abs() < f64::EPSILON);
assert!(msg.error_message.is_none());
match &msg.content[0] {
ContentBlock::Text { text } => assert_eq!(text, "Hello world"),
other => panic!("expected Text, got {other:?}"),
}
match &msg.content[1] {
ContentBlock::ToolCall {
id,
name,
arguments,
partial_json,
} => {
assert_eq!(id, "tc_1");
assert_eq!(name, "search");
assert_eq!(arguments, &serde_json::json!({"q": "rust"}));
assert!(partial_json.is_none());
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn accumulate_interleaved_text_and_tool_calls() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::ThinkingStart { content_index: 0 },
AssistantMessageEvent::ThinkingDelta {
content_index: 0,
delta: "Let me think".into(),
},
AssistantMessageEvent::ThinkingDelta {
content_index: 0,
delta: " about this.".into(),
},
AssistantMessageEvent::ThinkingEnd {
content_index: 0,
signature: Some("sig123".into()),
},
AssistantMessageEvent::TextStart { content_index: 1 },
AssistantMessageEvent::TextDelta {
content_index: 1,
delta: "I'll search for that.".into(),
},
AssistantMessageEvent::TextEnd { content_index: 1 },
AssistantMessageEvent::ToolCallStart {
content_index: 2,
id: "tc_a".into(),
name: "web_search".into(),
},
AssistantMessageEvent::ToolCallDelta {
content_index: 2,
delta: r#"{"query": "rust async"}"#.into(),
},
AssistantMessageEvent::ToolCallEnd { content_index: 2 },
AssistantMessageEvent::ToolCallStart {
content_index: 3,
id: "tc_b".into(),
name: "read_file".into(),
},
AssistantMessageEvent::ToolCallDelta {
content_index: 3,
delta: r#"{"path": "/tmp/foo.rs"}"#.into(),
},
AssistantMessageEvent::ToolCallEnd { content_index: 3 },
AssistantMessageEvent::Done {
stop_reason: StopReason::ToolUse,
usage: Usage {
input: 200,
output: 100,
cache_read: 10,
cache_write: 5,
total: 315,
..Default::default()
},
cost: Cost::default(),
},
];
let msg = accumulate_message(events, "openai", "gpt-4").unwrap();
assert_eq!(msg.content.len(), 4);
assert_eq!(msg.stop_reason, StopReason::ToolUse);
match &msg.content[0] {
ContentBlock::Thinking {
thinking,
signature,
} => {
assert_eq!(thinking, "Let me think about this.");
assert_eq!(signature.as_deref(), Some("sig123"));
}
other => panic!("expected Thinking, got {other:?}"),
}
match &msg.content[1] {
ContentBlock::Text { text } => assert_eq!(text, "I'll search for that."),
other => panic!("expected Text, got {other:?}"),
}
match &msg.content[2] {
ContentBlock::ToolCall {
id,
name,
arguments,
..
} => {
assert_eq!(id, "tc_a");
assert_eq!(name, "web_search");
assert_eq!(arguments, &serde_json::json!({"query": "rust async"}));
}
other => panic!("expected ToolCall, got {other:?}"),
}
match &msg.content[3] {
ContentBlock::ToolCall {
id,
name,
arguments,
..
} => {
assert_eq!(id, "tc_b");
assert_eq!(name, "read_file");
assert_eq!(arguments, &serde_json::json!({"path": "/tmp/foo.rs"}));
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn stream_options_defaults() {
let opts = StreamOptions::default();
assert!(opts.temperature.is_none());
assert!(opts.max_tokens.is_none());
assert!(opts.session_id.is_none());
assert!(opts.api_key.is_none());
assert_eq!(opts.transport, StreamTransport::Sse);
}
#[test]
fn stream_options_debug_redacts_api_key() {
let opts = StreamOptions {
api_key: Some("secret-key-123".to_string()),
..StreamOptions::default()
};
let debug = format!("{opts:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("secret-key-123"));
}
#[test]
fn stream_transport_serde_roundtrip() {
let transport = StreamTransport::Sse;
let json = serde_json::to_string(&transport).unwrap();
assert_eq!(json, r#""sse""#);
let parsed: StreamTransport = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, transport);
}
#[test]
fn accumulate_error_event() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::TextStart { content_index: 0 },
AssistantMessageEvent::TextDelta {
content_index: 0,
delta: "partial".into(),
},
AssistantMessageEvent::TextEnd { content_index: 0 },
AssistantMessageEvent::Error {
stop_reason: StopReason::Error,
error_message: "connection lost".into(),
error_kind: None,
usage: Some(Usage {
input: 50,
output: 10,
cache_read: 0,
cache_write: 0,
total: 60,
..Default::default()
}),
},
];
let msg = accumulate_message(events, "anthropic", "claude").unwrap();
assert_eq!(msg.stop_reason, StopReason::Error);
assert_eq!(msg.error_message.as_deref(), Some("connection lost"));
assert_eq!(msg.usage.total, 60);
assert_eq!(msg.content.len(), 1);
}
#[test]
fn accumulate_no_start_event() {
let events = vec![AssistantMessageEvent::TextStart { content_index: 0 }];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("before Start"));
}
#[test]
fn accumulate_no_terminal_event() {
let events = vec![AssistantMessageEvent::Start];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("terminal event"));
}
#[test]
fn accumulate_wrong_content_index() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::TextStart { content_index: 1 },
];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
}
#[test]
fn accumulate_delta_on_wrong_block_type() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::TextStart { content_index: 0 },
AssistantMessageEvent::ThinkingDelta {
content_index: 0,
delta: "oops".into(),
},
];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not Thinking"));
}
#[test]
fn accumulate_tool_call_empty_args() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::ToolCallStart {
content_index: 0,
id: "tc_0".into(),
name: "noop".into(),
},
AssistantMessageEvent::ToolCallEnd { content_index: 0 },
AssistantMessageEvent::Done {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
cost: Cost::default(),
},
];
let msg = accumulate_message(events, "p", "m").unwrap();
match &msg.content[0] {
ContentBlock::ToolCall { arguments, .. } => {
assert!(arguments.is_object());
assert!(arguments.as_object().unwrap().is_empty());
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn accumulate_error_event_without_usage() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::Error {
stop_reason: StopReason::Error,
error_message: "fatal".into(),
error_kind: None,
usage: None,
},
];
let msg = accumulate_message(events, "p", "m").unwrap();
assert_eq!(msg.stop_reason, StopReason::Error);
assert_eq!(msg.error_message.as_deref(), Some("fatal"));
assert_eq!(msg.usage, Usage::default());
}
#[test]
fn duplicate_start_event_errors() {
let events = vec![AssistantMessageEvent::Start, AssistantMessageEvent::Start];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("duplicate Start"));
}
#[test]
fn tool_call_delta_invalid_index() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::ToolCallDelta {
content_index: 99,
delta: "oops".into(),
},
];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid content_index"));
}
#[test]
fn thinking_only_response() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::ThinkingStart { content_index: 0 },
AssistantMessageEvent::ThinkingDelta {
content_index: 0,
delta: "Let me ".into(),
},
AssistantMessageEvent::ThinkingDelta {
content_index: 0,
delta: "ponder this.".into(),
},
AssistantMessageEvent::ThinkingEnd {
content_index: 0,
signature: None,
},
AssistantMessageEvent::Done {
stop_reason: StopReason::Stop,
usage: Usage::default(),
cost: Cost::default(),
},
];
let msg = accumulate_message(events, "anthropic", "claude").unwrap();
assert_eq!(msg.content.len(), 1);
assert_eq!(msg.stop_reason, StopReason::Stop);
assert!(msg.error_message.is_none());
match &msg.content[0] {
ContentBlock::Thinking {
thinking,
signature,
} => {
assert_eq!(thinking, "Let me ponder this.");
assert!(signature.is_none());
}
other => panic!("expected Thinking, got {other:?}"),
}
}
#[test]
fn error_event_with_usage() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::Error {
stop_reason: StopReason::Error,
error_message: "overloaded".into(),
error_kind: None,
usage: Some(Usage {
input: 42,
output: 7,
cache_read: 0,
cache_write: 0,
total: 49,
..Default::default()
}),
},
];
let msg = accumulate_message(events, "p", "m").unwrap();
assert_eq!(msg.stop_reason, StopReason::Error);
assert_eq!(msg.error_message.as_deref(), Some("overloaded"));
assert_eq!(msg.usage.input, 42);
assert_eq!(msg.usage.output, 7);
assert_eq!(msg.usage.total, 49);
}
#[test]
fn text_delta_on_wrong_block_type() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::ThinkingStart { content_index: 0 },
AssistantMessageEvent::TextDelta {
content_index: 0,
delta: "wrong target".into(),
},
];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not Text"));
}
#[test]
fn accumulate_empty_stream() {
let events = vec![];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("no Start event"));
}
#[test]
fn assistant_message_delta_variants() {
use std::borrow::Cow;
use swink_agent::AssistantMessageDelta;
let text = AssistantMessageDelta::Text {
content_index: 0,
delta: Cow::Borrowed("hello"),
};
assert!(matches!(
text,
AssistantMessageDelta::Text {
content_index: 0,
..
}
));
let thinking = AssistantMessageDelta::Thinking {
content_index: 1,
delta: Cow::Owned("pondering".to_string()),
};
assert!(matches!(
thinking,
AssistantMessageDelta::Thinking {
content_index: 1,
..
}
));
let tool = AssistantMessageDelta::ToolCall {
content_index: 2,
delta: Cow::Borrowed(r#"{"key":"val"}"#),
};
assert!(matches!(
tool,
AssistantMessageDelta::ToolCall {
content_index: 2,
..
}
));
}
#[test]
fn duplicate_done_rejected() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::TextStart { content_index: 0 },
AssistantMessageEvent::TextDelta {
content_index: 0,
delta: "hello".into(),
},
AssistantMessageEvent::TextEnd { content_index: 0 },
AssistantMessageEvent::Done {
stop_reason: StopReason::Stop,
usage: Usage::default(),
cost: Cost::default(),
},
AssistantMessageEvent::Done {
stop_reason: StopReason::Stop,
usage: Usage::default(),
cost: Cost::default(),
},
];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("duplicate terminal"));
}
#[test]
fn duplicate_error_rejected() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::Error {
stop_reason: StopReason::Error,
error_message: "first".into(),
usage: None,
error_kind: None,
},
AssistantMessageEvent::Error {
stop_reason: StopReason::Error,
error_message: "second".into(),
usage: None,
error_kind: None,
},
];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("duplicate terminal"));
}
#[test]
fn content_after_done_rejected() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::Done {
stop_reason: StopReason::Stop,
usage: Usage::default(),
cost: Cost::default(),
},
AssistantMessageEvent::TextDelta {
content_index: 0,
delta: "late".into(),
},
];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("after terminal"));
}
#[test]
fn content_after_error_rejected() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::Error {
stop_reason: StopReason::Error,
error_message: "oops".into(),
usage: None,
error_kind: None,
},
AssistantMessageEvent::TextDelta {
content_index: 0,
delta: "late".into(),
},
];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("after terminal"));
}
#[test]
fn done_then_error_rejected() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::Done {
stop_reason: StopReason::Stop,
usage: Usage::default(),
cost: Cost::default(),
},
AssistantMessageEvent::Error {
stop_reason: StopReason::Error,
error_message: "late error".into(),
usage: None,
error_kind: None,
},
];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("duplicate terminal"));
}
#[test]
fn error_then_done_rejected() {
let events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::Error {
stop_reason: StopReason::Error,
error_message: "error first".into(),
usage: None,
error_kind: None,
},
AssistantMessageEvent::Done {
stop_reason: StopReason::Stop,
usage: Usage::default(),
cost: Cost::default(),
},
];
let result = accumulate_message(events, "p", "m");
assert!(result.is_err());
assert!(result.unwrap_err().contains("duplicate terminal"));
}