mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use common::{fixed_clock, fixed_random, fixed_run_id};
use salvor_core::Event;
use salvor_llm::{Client, Config, Message, MessageRequest};
use salvor_runtime::{RunCtx, RuntimeError};
use salvor_store::{EventStore, SqliteStore};
use serde_json::{Value, json};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
const DEF_HASH: &str = "sha256:streaming-model-call-v1";
fn non_streaming_body() -> Value {
json!({
"id": "msg_stream_parity",
"model": "test-model",
"role": "assistant",
"content": [{"type": "text", "text": "the plan: study otters"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5}
})
}
fn frame(event: &str, data: &Value) -> String {
format!("event: {event}\ndata: {data}\n\n")
}
fn equivalent_sse_body() -> String {
let mut body = frame(
"message_start",
&json!({
"type": "message_start",
"message": {
"id": "msg_stream_parity",
"type": "message",
"model": "test-model",
"role": "assistant",
"content": [],
"stop_reason": null,
"usage": {"input_tokens": 10, "output_tokens": 0}
}
}),
);
body.push_str(&frame(
"content_block_start",
&json!({
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""}
}),
));
body.push_str(&frame(
"content_block_delta",
&json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "the plan: "}
}),
));
body.push_str(&frame(
"content_block_delta",
&json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "study otters"}
}),
));
body.push_str(&frame(
"content_block_stop",
&json!({"type": "content_block_stop", "index": 0}),
));
body.push_str(&frame(
"message_delta",
&json!({
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": null},
"usage": {"output_tokens": 5}
}),
));
body.push_str(&frame("message_stop", &json!({"type": "message_stop"})));
body
}
fn sse_response(body: String) -> ResponseTemplate {
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(body)
}
fn client_for(uri: &str) -> Client {
Client::new(Config::new().with_base_url(uri).with_max_retries(0)).expect("client builds")
}
fn request() -> MessageRequest {
MessageRequest::new("test-model", 256).push_message(Message::user("draft a plan"))
}
#[tokio::test]
async fn both_paths_record_a_byte_identical_log() {
let json_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(ResponseTemplate::new(200).set_body_json(non_streaming_body()))
.mount(&json_server)
.await;
let json_client = client_for(&json_server.uri());
let json_store: Arc<dyn EventStore> = Arc::new(SqliteStore::in_memory().expect("store opens"));
let run_id = fixed_run_id(60);
let mut ctx = RunCtx::with_hooks(
json_store.clone(),
run_id,
Vec::new(),
fixed_clock(),
fixed_random(),
)
.expect("ctx builds");
ctx.begin(DEF_HASH, &json!("draft a plan"))
.await
.expect("begin");
let plain = ctx
.model_call(&json_client, &request())
.await
.expect("non-streaming call");
drop(ctx);
let non_streaming_log = json_store.read_log(run_id).await.expect("log reads");
let sse_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(sse_response(equivalent_sse_body()))
.mount(&sse_server)
.await;
let sse_client = client_for(&sse_server.uri());
let sse_store: Arc<dyn EventStore> = Arc::new(SqliteStore::in_memory().expect("store opens"));
let mut ctx = RunCtx::with_hooks(
sse_store.clone(),
run_id,
Vec::new(),
fixed_clock(),
fixed_random(),
)
.expect("ctx builds");
ctx.begin(DEF_HASH, &json!("draft a plan"))
.await
.expect("begin");
let mut events_seen = 0usize;
let streamed = ctx
.model_call_streaming(&sse_client, &request(), |_event| events_seen += 1)
.await
.expect("streaming call");
drop(ctx);
let streaming_log = sse_store.read_log(run_id).await.expect("log reads");
assert!(events_seen > 0, "the live stream must feed the ticker");
assert_eq!(plain.response, streamed.response, "same typed response");
assert_eq!(plain.usage, streamed.usage, "same recorded usage");
assert_eq!(
non_streaming_log, streaming_log,
"streaming and non-streaming must record byte-identical logs"
);
let Event::ModelCallCompleted {
response: plain_response,
usage: plain_usage,
..
} = &non_streaming_log[2].event
else {
panic!("non-streaming seq 2 is the completion");
};
let Event::ModelCallCompleted {
response: streamed_response,
usage: streamed_usage,
..
} = &streaming_log[2].event
else {
panic!("streaming seq 2 is the completion");
};
assert_eq!(plain_response, streamed_response, "recorded response value");
assert_eq!(plain_usage, streamed_usage, "recorded usage");
}
#[tokio::test]
async fn replay_of_a_streamed_run_makes_no_live_call() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(sse_response(equivalent_sse_body()))
.expect(1) .mount(&server)
.await;
let client = client_for(&server.uri());
let store: Arc<dyn EventStore> = Arc::new(SqliteStore::in_memory().expect("store opens"));
let run_id = fixed_run_id(61);
let mut ctx = RunCtx::with_hooks(
store.clone(),
run_id,
Vec::new(),
fixed_clock(),
fixed_random(),
)
.expect("ctx builds");
ctx.begin(DEF_HASH, &json!("draft a plan"))
.await
.expect("begin");
let mut live_events = 0usize;
let live = ctx
.model_call_streaming(&client, &request(), |_| live_events += 1)
.await
.expect("live streaming call");
drop(ctx);
assert!(live_events > 0, "the live drive streamed events");
let log = store.read_log(run_id).await.expect("log reads");
let mut ctx = RunCtx::with_hooks(store.clone(), run_id, log, fixed_clock(), fixed_random())
.expect("ctx builds over the recorded log");
ctx.begin(DEF_HASH, &json!("draft a plan"))
.await
.expect("begin replays");
let mut replay_events = 0usize;
let replayed = ctx
.model_call_streaming(&client, &request(), |_| replay_events += 1)
.await
.expect("replayed streaming call");
assert_eq!(
replay_events, 0,
"replay has no live tokens, so the ticker never fires"
);
assert_eq!(
replayed.response, live.response,
"replay returns the recorded response exactly"
);
assert_eq!(
replayed.usage, live.usage,
"replay returns the recorded usage"
);
let requests = server.received_requests().await.expect("requests recorded");
assert_eq!(requests.len(), 1, "the replayed call never hit the server");
}
struct ErrorThenGoodStream {
calls: AtomicUsize,
}
impl Respond for ErrorThenGoodStream {
fn respond(&self, _request: &Request) -> ResponseTemplate {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
let mut body = frame(
"message_start",
&json!({
"type": "message_start",
"message": {
"id": "msg_stream_parity",
"type": "message",
"model": "test-model",
"role": "assistant",
"content": [],
"stop_reason": null,
"usage": {"input_tokens": 10, "output_tokens": 0}
}
}),
);
body.push_str(&frame(
"error",
&json!({
"type": "error",
"error": {"type": "overloaded_error", "message": "the stream fell over"}
}),
));
sse_response(body)
} else {
sse_response(equivalent_sse_body())
}
}
}
#[tokio::test]
async fn mid_stream_error_leaves_a_dangling_intent_that_reissues_on_resume() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(ErrorThenGoodStream {
calls: AtomicUsize::new(0),
})
.mount(&server)
.await;
let client = client_for(&server.uri());
let store: Arc<dyn EventStore> = Arc::new(SqliteStore::in_memory().expect("store opens"));
let run_id = fixed_run_id(62);
let mut ctx = RunCtx::with_hooks(
store.clone(),
run_id,
Vec::new(),
fixed_clock(),
fixed_random(),
)
.expect("ctx builds");
ctx.begin(DEF_HASH, &json!("draft a plan"))
.await
.expect("begin");
let mut events_before_error = 0usize;
let result = ctx
.model_call_streaming(&client, &request(), |_| events_before_error += 1)
.await;
assert!(
matches!(result, Err(RuntimeError::Model(_))),
"a mid-stream error surfaces as RuntimeError::Model, got {result:?}"
);
drop(ctx);
let log = store.read_log(run_id).await.expect("log reads");
assert!(
matches!(
log.last().map(|envelope| &envelope.event),
Some(Event::ModelCallRequested { .. })
),
"the write-ahead intent is durable with no completion (dangling intent)"
);
let completions = log
.iter()
.filter(|e| matches!(e.event, Event::ModelCallCompleted { .. }))
.count();
assert_eq!(
completions, 0,
"no completion was recorded for the failed stream"
);
let mut ctx = RunCtx::with_hooks(store.clone(), run_id, log, fixed_clock(), fixed_random())
.expect("ctx builds over the recorded log");
ctx.begin(DEF_HASH, &json!("draft a plan"))
.await
.expect("begin replays");
let recovered = ctx
.model_call_streaming(&client, &request(), |_| {})
.await
.expect("the re-issued call succeeds");
assert_eq!(recovered.response.text(), "the plan: study otters");
drop(ctx);
let final_log = store.read_log(run_id).await.expect("log reads");
let completions = final_log
.iter()
.filter(|e| matches!(e.event, Event::ModelCallCompleted { .. }))
.count();
assert_eq!(
completions, 1,
"the re-issued call recorded exactly one completion"
);
}