Replay
- In one line
- Re-materializes a recorded session trajectory as the same AgentResponseStream a live provider turn produces, with no network call.
- You need
- The
service-replayfeature, which enablesplugin-session-log, plus oneprovider-*feature. - Read this if
- You are replaying a recorded turn for a regression fixture, an offline eval, or a fork-point comparison.
Feature and entry type
| Item | Value |
|---|---|
| Feature flag | service-replay |
| Declared plugin edge | plugin-session-log, hard: service-replay = ["plugin-session-log"] |
| Entry type | SessionReplay |
| Other public types | ReplayConfig, ReplayTrajectory, ReplayTurn, ReplayUsage, ReplayCompletion, ReplayNote |
SessionReplay is an explicit-call service. It never implements CucaPlugin, so passing it to register_plugin is a compile error. The session-log module does not name replay, so the dependency runs one way. Replay reads a recording through the SessionBackend seam and never writes; writing a trajectory stays with SessionStorePlugin.
Entry points
Every entry point is a synchronous method call.
Bind a backend
SessionReplay::new(backend)binds anArc<dyn SessionBackend>with the defaultReplayConfig.SessionReplay::with_config(backend, config)validatesconfigand returnsResult<SessionReplay, PluginError>.- The backend is
SessionLogPlugin::backend()for a registered log, orFileBackend::new(dir)for a directory of.cslogfiles.
Load a trajectory
| Method | Retains |
|---|---|
load(session_id) | the whole recorded session, in append order |
load_prefix(session_id, upto_sequence) | records with sequence <= upto_sequence |
load_at_point(point_id) | the same prefix, addressed as "{session_id}:{sequence}", the string SessionStorePlugin::fork_session takes |
- Every load reads through
SessionBackend::replay, which has no ranged read. A fork-point load therefore reads the whole session before it filters. - A session id the backend does not know loads as an empty trajectory with
InMemoryBackendandFileBackend.load_prefixandload_at_pointrefuse it because no record matches. load_at_pointsplits at the last:, since a fork session id can itself contain:.
Segmentation into turns
- A turn closes on the
LatencythenTokenUsagepair thatSessionLogPlugin::on_response_completealways appends. - Records after the last pair form a final turn with
completion() == Noneandis_complete() == false. They are never merged into the previous turn and never dropped. - Each
SessionEventlands in the turn it belongs to:SystemPromptgoes toReplayTurn::system_prompts.Messagegoes toReplayTurn::messages.Reasoning,Output,ToolCallandToolResultbecome blocks (Thinking,Text,ToolCall,ToolResult).LatencyandTokenUsagefillReplayTurn::completion, aReplayCompletionwithduration_ms,prompt_tokensandcompletion_tokens.ModelSwapandForkgo toReplayTurn::notesasReplayNote::ModelSwap { from, to, reason }andReplayNote::Fork { from_point, to_session }.
Read and stream a turn
| Method | Blocks | Errors on |
|---|---|---|
ReplayTrajectory::stream_turn(index) | clones one turn's blocks; the trajectory stays replayable | an index that names no turn |
ReplayTrajectory::into_stream() | moves every turn's blocks, concatenated in record order | a trajectory with no turns |
ReplayTurn::stream() | clones this turn's blocks | never |
ReplayTurn::into_stream() | moves this turn's blocks | never |
ReplayTrajectoryalso exposessession_id(),len(),is_empty(),usage(),turns()andturn(index).ReplayTurnalso exposesblocks(),completion(),notes(),sequence_range()andis_complete().ReplayTurn::response(model, provider)rebuilds the aggregatedUnifiedResponsefor code written againston_response_complete. The caller suppliesmodelandproviderbecause no record holds them.finish_reason,prompt_cache_usageandusageare alwaysNone, and a turn with no terminator reports zero tokens and zero duration.- The stream is order-deterministic, not time-deterministic. Its
poll_nextnever sleeps, never returnsPoll::Pendingand never yieldsErr, so two loads of one trajectory give identical block sequences. The recorded latency is data inReplayCompletion::duration_ms.
Hand-off: replayed blocks skip the plugin pipeline
Replay streams and responses go straight to the caller. No registered plugin's on_stream_chunk or on_response_complete runs on them, because no CucaClient::generate_stream call routes them. A consumer that needs those hooks calls them itself.
Fidelity gaps
Replay cannot return data the recording never held, and nothing is synthesized in its place.
MessageContentBlock::ImageBase64is never recorded, becauseSessionLogPlugin::on_stream_chunkmaps it to no event.SessionEvent::ToolResultcarriesstdout,stderrandexit_code, whichMessageContentBlock::ToolResulthas no field for. A caller that needs them reads the raw records withSessionBackend::replay.
Configuration
ReplayConfig::new(max_records, max_turn_blocks, warn_fraction) is the validating constructor. The fields are public, so validate() re-checks a struct literal, and SessionReplay::with_config calls it.
| Key | Default | Valid values |
|---|---|---|
max_records | 65536 (ReplayConfig::DEFAULT_MAX_RECORDS) | non-zero |
max_turn_blocks | 4096 (ReplayConfig::DEFAULT_MAX_TURN_BLOCKS) | non-zero |
warn_fraction | Some(0.9) | None, or finite and in (0.0, 1.0] |
max_recordsis the most records one load may retain.max_turn_blocksis the most content blocks one turn may retain.warn_fractionis the fraction ofmax_recordsat whichReplayUsage::near_capturnstrue;Nonekeeps itfalse.
Caps and at-cap policy
| Bound | At-cap policy | Usage gauge |
|---|---|---|
max_records, records one load retains | refuse with PluginError::Validation, never truncate | usage().records against usage().max_records |
max_turn_blocks, blocks one turn retains | refuse with PluginError::Validation, never truncate | usage().blocks, the total across all turns |
- A silently shortened trajectory is a wrong fixture and a wrong eval, so replay refuses.
ReplayUsageis computed once at load and holdsrecords,max_records,blocks,turnsandnear_cap. A loaded trajectory never grows, so the warning is a field to read and no message is injected.- The pre-read bound belongs to the backend.
SessionBackend::replayreturns the wholeVec<SessionRecord>beforeReplayConfigfilters it, soInMemoryBackend::DEFAULT_MAX_RECORDScaps an in-memory session and disk caps aFileBackendsession. load*moves thatVecinto the trajectory, so the steady state is one trajectory and not a trajectory plus its source records.SessionReplayholds oneArcand oneCopyconfig, so it does not grow with traffic.
Errors
Replay has no error type of its own. Every method returns PluginError, and each refusal is PluginError::Validation { schema, message }.
schema | Raised by | When |
|---|---|---|
max_records | ReplayConfig::new, validate, with_config, load* | the cap is zero, or the retained records exceed it |
max_turn_blocks | ReplayConfig::new, validate, with_config, load* | the cap is zero, or one turn's blocks exceed it |
warn_fraction | ReplayConfig::new, validate, with_config | the value is not finite or lies outside (0.0, 1.0] |
sequence | load* | record sequences are not strictly increasing |
point_id | load_prefix, load_at_point | no record sits at or below the sequence; or the id has no :, an empty session id, or a sequence that is not a u64 |
turn_index | ReplayTrajectory::stream_turn | the index names no turn, including every index on an empty trajectory |
session_id | ReplayTrajectory::into_stream | the trajectory holds no turns |
SessionBackend::replayerrors pass throughload*unchanged.- A materialized stream never yields
Err, because every failure is raised at load time.
Example
This program replays turn 0 of session demo from a directory of .cslog files that a SessionLogPlugin wrote earlier. It sends no request, so it needs no server.
use std::sync::Arc;
use cuca::{FileBackend, SessionReplay};
use tokio_stream::StreamExt;
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let replay = SessionReplay::new(Arc::new(FileBackend::new("sessions")?));
let trajectory = replay.load("demo")?;
let mut stream = trajectory.stream_turn(0)?;
while let Some(block) = stream.next().await {
println!("{:?}", block?);
}
Ok(())
}
examples/replay.rs records one live turn through a SessionLogPlugin over a temp-directory FileBackend, drops the client, then replays from a fresh FileBackend over the same directory. It also rebuilds the UnifiedResponse and replays a fork-point prefix. It needs a llama-server on port 1234 with the demo model loaded; CUCA_BASE_URL and CUCA_MODEL point it at any OpenAI-compatible server.
cargo run --example replay --features "provider-llamacpp service-replay"Related pages
- Session log records the trajectories replay reads.
- Services lists every service and its plugin edge.
- Plugins and services explains the explicit-call tier.
- Memory discipline explains the bounded-by-default rule.