use super::SessionEvent;
#[cfg(unix)]
use super::{SessionEventKind, TurnStatus};
#[cfg(unix)]
use crate::output::sanitize_display_controls;
#[cfg(unix)]
pub(crate) const CHAT_MAX_BYTES: usize = 8 * 1024 * 1024;
#[cfg(unix)]
pub(crate) const CHAT_MAX_ENTRIES: usize = 10_000;
#[cfg(unix)]
#[derive(Debug)]
pub(crate) struct ChatMessage {
pub(crate) kind: &'static str,
pub(crate) text: String,
pub(crate) partial: bool,
}
pub(crate) fn reconciled_assistant_output(output: &str, prior: &str, current: &str) -> String {
match output.strip_prefix(prior) {
Some(suffix) if !prior.is_empty() && suffix.starts_with(current) => suffix.to_owned(),
_ => output.to_owned(),
}
}
pub(crate) fn tool_call(event: &SessionEvent) -> Option<crate::providers::ToolCall> {
Some(crate::providers::ToolCall {
id: event.payload.get("id")?.as_str()?.to_owned(),
name: event.payload.get("name")?.as_str()?.to_owned(),
arguments: event
.payload
.get("arguments")
.cloned()
.unwrap_or_else(|| serde_json::json!({})),
})
}
pub(crate) fn tool_result(
event: &SessionEvent,
calls: &std::collections::HashMap<String, crate::providers::ToolCall>,
) -> Result<(crate::providers::ToolCall, crate::tools::ToolResult), String> {
let value = event.payload.get("result").ok_or_else(|| {
"session replay warning: tool_result event missing result payload".to_owned()
})?;
let result: crate::tools::ToolResult = serde_json::from_value(value.clone()).map_err(|_| {
"session replay warning: tool_result event has malformed result payload".to_owned()
})?;
let id = event
.payload
.get("call_id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let call = calls
.get(id)
.cloned()
.unwrap_or_else(|| crate::providers::ToolCall {
id: id.to_owned(),
name: result.tool_name.clone(),
arguments: serde_json::json!({}),
});
Ok((call, result))
}
#[cfg(unix)]
#[derive(Default)]
struct Projection {
messages: Vec<ChatMessage>,
bytes: usize,
current: Option<usize>,
prior: String,
turn_start: usize,
has_output: bool,
terminal: bool,
incomplete: bool,
turn_partial: bool,
calls: std::collections::HashMap<String, crate::providers::ToolCall>,
tool_entries: std::collections::HashMap<String, usize>,
reasoning_entries: std::collections::HashMap<(String, String), usize>,
}
#[cfg(unix)]
impl Projection {
fn push(
&mut self,
kind: &'static str,
text: &str,
partial: bool,
) -> Result<usize, &'static str> {
let text = sanitize_display_controls(text);
self.bytes = self.bytes.saturating_add(text.len());
if self.bytes > CHAT_MAX_BYTES || self.messages.len() == CHAT_MAX_ENTRIES {
return Err("history_limit");
}
let index = self.messages.len();
self.messages.push(ChatMessage {
kind,
text,
partial,
});
Ok(index)
}
fn replace(&mut self, index: usize, text: &str, partial: bool) -> Result<(), &'static str> {
let message = &mut self.messages[index];
let text = sanitize_display_controls(text);
self.bytes = self
.bytes
.saturating_sub(message.text.len())
.saturating_add(text.len());
if self.bytes > CHAT_MAX_BYTES {
return Err("history_limit");
}
message.text = text;
message.partial = partial;
Ok(())
}
fn assistant(&mut self, text: &str, replace: bool) -> Result<(), &'static str> {
if text.is_empty() {
return Ok(());
}
if let Some(index) = self.current {
let message = &mut self.messages[index];
let text = sanitize_display_controls(text);
let added_bytes = text.len();
if replace {
self.bytes = self.bytes.saturating_sub(message.text.len());
message.text = text;
} else {
message.text.push_str(&text);
}
self.bytes = self.bytes.saturating_add(added_bytes);
if self.bytes > CHAT_MAX_BYTES {
return Err("history_limit");
}
} else {
self.current = Some(self.push("assistant", text, true)?);
}
Ok(())
}
fn boundary(&mut self) {
if let Some(index) = self.current.take() {
self.prior.push_str(&self.messages[index].text);
}
}
fn finish_turn(&mut self) -> Result<(), &'static str> {
if !self.terminal
&& (!self.calls.is_empty()
|| (!self.has_output
&& self.messages[self.turn_start..]
.iter()
.any(|m| m.kind == "user" || m.kind == "assistant")))
{
self.incomplete = true;
self.turn_partial = true;
self.push(
"diagnostic",
"Interrupted turn; no durable completion recorded",
true,
)?;
}
if self.terminal || self.has_output {
if !self.turn_partial {
for message in &mut self.messages[self.turn_start..] {
if message.kind == "assistant" {
message.partial = false;
}
}
}
}
self.current = None;
self.prior.clear();
self.calls.clear();
self.tool_entries.clear();
self.reasoning_entries.clear();
self.turn_start = self.messages.len();
self.has_output = false;
self.terminal = false;
self.turn_partial = false;
Ok(())
}
}
#[cfg(unix)]
pub(crate) fn project(events: &[SessionEvent]) -> Result<(Vec<ChatMessage>, bool), &'static str> {
let mut projection = Projection::default();
for event in events {
let text = |key| {
event
.payload
.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
};
let required_text = |key| {
event
.payload
.get(key)
.and_then(serde_json::Value::as_str)
.ok_or("session_invalid")
};
match event.kind() {
Some(SessionEventKind::UserInput | SessionEventKind::Compaction) => {
projection.finish_turn()?;
if event.kind() == Some(SessionEventKind::Compaction) {
projection.push("compaction", required_text("summary")?, false)?;
} else if !matches!(
text("origin"),
"automatic_compaction" | "automatic_continuation" | "provider_only"
) {
projection.push("user", required_text("text")?, false)?;
}
}
Some(SessionEventKind::AssistantChunk) => {
projection.assistant(required_text("text")?, false)?
}
Some(SessionEventKind::AssistantOutput) => {
let current = projection
.current
.map(|i| projection.messages[i].text.as_str())
.unwrap_or_default();
let output = if event.payload.get("text").is_some() {
required_text("text")?
} else {
current
};
let output = reconciled_assistant_output(
&sanitize_display_controls(output),
&projection.prior,
current,
);
projection.assistant(&output, true)?;
projection.has_output = true;
projection.prior.clear();
}
Some(SessionEventKind::ReasoningSummary) => {
let summary = required_text("text")?;
if event
.payload
.get("provider_summary")
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
continue;
}
projection.boundary();
if let Some(item_id) = event
.payload
.get("item_id")
.and_then(serde_json::Value::as_str)
{
let identity = (text("turn_id").to_owned(), item_id.to_owned());
if let Some(&index) = projection.reasoning_entries.get(&identity) {
projection.replace(index, summary, false)?;
} else {
let index = projection.push("reasoning_summary", summary, false)?;
projection.reasoning_entries.insert(identity, index);
}
} else {
projection.push("reasoning_summary", summary, false)?;
}
}
Some(SessionEventKind::ToolCall | SessionEventKind::CodeModeToolCall) => {
projection.boundary();
let call = tool_call(event).ok_or("session_invalid")?;
let index = projection.push(
"activity",
&format!("{}: started", crate::output::tool_display_label(&call)),
true,
)?;
projection.tool_entries.insert(call.id.clone(), index);
projection.calls.insert(call.id.clone(), call);
}
Some(
SessionEventKind::ToolResult
| SessionEventKind::ToolDisplayResult
| SessionEventKind::CodeModeToolResult,
) => {
projection.boundary();
let (call, result) =
tool_result(event, &projection.calls).map_err(|_| "session_invalid")?;
projection.calls.remove(&call.id);
let summary = crate::output::tool_display_summary(&call, &result);
let text = format!(
"{}: {}",
summary.label,
if result.success {
"completed"
} else {
"failed"
}
);
if let Some(index) = projection.tool_entries.remove(&call.id) {
projection.replace(index, &text, false)?;
} else {
projection.push("activity", &text, false)?;
}
}
Some(SessionEventKind::TurnStatus) => {
let status = event.turn_status_payload().ok_or("session_invalid")?;
let partial = status.status != TurnStatus::Complete;
projection.turn_partial = partial;
if !projection.messages[projection.turn_start..]
.iter()
.any(|m| m.kind == "assistant")
&& let Some(text) = status.assistant_text.as_deref()
{
projection.assistant(text, true)?;
}
for message in &mut projection.messages[projection.turn_start..] {
if message.kind == "assistant" {
message.partial = partial;
}
}
if partial {
projection.push("diagnostic", &format!("Turn {:?}", status.status), true)?;
if let Some(error) = status.error_summary {
projection.push("diagnostic", &error, true)?;
}
}
projection.terminal = true;
projection.calls.clear();
}
Some(
SessionEventKind::Diagnostic
| SessionEventKind::HookDiagnostic
| SessionEventKind::CodeModeWarning,
) => {
let key = if event.kind() == Some(SessionEventKind::CodeModeWarning) {
"warning"
} else {
"message"
};
if event.payload.get(key).is_some() {
projection.boundary();
projection.push("diagnostic", required_text(key)?, false)?;
}
}
_ => {}
}
}
projection.finish_turn()?;
let complete = !projection.incomplete;
Ok((projection.messages, complete))
}