use std::collections::HashSet;
use oino_ai::{
AgentMessage, AssistantContent, JsonObject, ToolCall, ToolResultMessage, UserContent,
};
use serde_json::json;
use super::{SessionManager, files};
use crate::entry::EntryId;
use crate::error::SessionError;
use crate::time::now_ms;
pub const TOOL_DISPATCH_TYPE: &str = "oino.tool-dispatch";
pub const RESUME_REPAIR_TYPE: &str = "oino.resume-repair";
pub const INTERRUPTED_TOOL_TEXT: &str = "Tool execution was interrupted before completion; the outcome is unknown. The effect may or may not have happened. Do not retry blindly: verify the result first, and ask the user if verification is not possible.";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RepairReport {
pub closed_tool_calls: Vec<String>,
pub torn_tail: bool,
}
impl SessionManager {
pub fn append_tool_dispatch(
&mut self,
tool_call_id: &str,
tool_name: &str,
input: &JsonObject,
) -> Result<EntryId, SessionError> {
self.append_custom_entry(
TOOL_DISPATCH_TYPE,
Some(json!({
"toolCallId": tool_call_id,
"toolName": tool_name,
"input": input,
"readOnly": false,
})),
)
}
pub fn repair_interrupted_turn(&mut self) -> Result<RepairReport, SessionError> {
let mut report = RepairReport::default();
let Some(path) = self
.session_file
.clone()
.filter(|_| self.persist && self.flushed)
else {
return Ok(report);
};
if torn_tail(&files::read_to_string(&path)?) {
self.write_appended(&path, "")?;
report.torn_tail = true;
}
for call in self.dangling_tool_calls() {
self.append_message(AgentMessage::ToolResult(ToolResultMessage {
tool_call_id: call.id.clone(),
tool_name: call.name.clone(),
content: vec![UserContent::text(INTERRUPTED_TOOL_TEXT)],
details: None,
usage: None,
nested_calls: None,
is_error: true,
timestamp: now_ms(),
duration_ms: None,
}))?;
report.closed_tool_calls.push(call.id);
}
if report.torn_tail || !report.closed_tool_calls.is_empty() {
self.append_custom_entry(
RESUME_REPAIR_TYPE,
Some(json!({
"closedToolCalls": report.closed_tool_calls,
"tornTail": report.torn_tail,
"at": now_ms(),
})),
)?;
self.sync()?;
}
Ok(report)
}
fn dangling_tool_calls(&self) -> Vec<ToolCall> {
let messages = &self.build_session_projection().messages;
let mut start = messages.len();
while start > 0 && matches!(messages[start - 1], AgentMessage::ToolResult(_)) {
start -= 1;
}
let Some(AgentMessage::Assistant(assistant)) =
start.checked_sub(1).and_then(|index| messages.get(index))
else {
return Vec::new();
};
let answered: HashSet<&str> = messages[start..]
.iter()
.filter_map(|message| match message {
AgentMessage::ToolResult(result) => Some(result.tool_call_id.as_str()),
_ => None,
})
.collect();
assistant
.content
.iter()
.filter_map(|block| match block {
AssistantContent::ToolCall(call) => {
(!answered.contains(call.id.as_str())).then(|| call.clone())
}
_ => None,
})
.collect()
}
}
fn torn_tail(text: &str) -> bool {
match text.split('\n').next_back() {
Some(last) => !last.is_empty() && serde_json::from_str::<serde_json::Value>(last).is_err(),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_torn_tail_only_for_invalid_fragments() {
assert!(!torn_tail("{}\n"));
assert!(!torn_tail("{}\n{\"id\":\"a\"}"));
assert!(torn_tail("{}\n{\"type\":\"mess"));
assert!(torn_tail("{\"type\":\"mess"));
assert!(!torn_tail(""));
}
}