use super::*;
pub(super) fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
let mut owner_positions: HashMap<String, usize> = HashMap::new();
for (index, m) in messages.iter().enumerate() {
if m.role == Role::Assistant {
for c in m.tool_calls() {
if !c.id.is_empty() {
owner_positions.entry(c.id.clone()).or_insert(index);
}
}
}
}
let mut contiguous_owner = None;
let needs_reorder =
messages
.iter()
.enumerate()
.any(|(message_index, message)| match message.role {
Role::Assistant => {
contiguous_owner = Some(message_index);
false
}
Role::Tool => match message
.tool_call_id
.as_deref()
.and_then(|id| owner_positions.get(id))
.copied()
{
Some(owner) => Some(owner) != contiguous_owner,
None => {
contiguous_owner = None;
false
}
},
_ => {
contiguous_owner = None;
false
}
});
if !needs_reorder {
return;
}
let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
let mut call_owner: HashMap<String, usize> = HashMap::new();
let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
let drained: Vec<ChatMessage> = std::mem::take(messages);
for (orig_pos, msg) in drained.into_iter().enumerate() {
if msg.role == Role::Tool {
let is_owned = msg
.tool_call_id
.as_deref()
.map(|id| !id.is_empty() && owner_positions.contains_key(id))
.unwrap_or(false);
if is_owned {
owned_results.push((orig_pos, msg));
continue;
}
spine.push(msg);
continue;
}
if msg.role == Role::Assistant {
let spine_idx = spine.len();
for c in msg.tool_calls() {
if !c.id.is_empty() {
call_owner.entry(c.id.clone()).or_insert(spine_idx);
}
}
}
spine.push(msg);
}
let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
for (orig_pos, msg) in owned_results.into_iter() {
let id = msg
.tool_call_id
.as_deref()
.filter(|id| !id.is_empty())
.expect("routed as owned, so tool_call_id must be a non-empty owned id");
let idx = *call_owner
.get(id)
.expect("owned id must have an owning assistant in call_owner");
buckets.entry(idx).or_default().push((orig_pos, msg));
}
for v in buckets.values_mut() {
v.sort_by_key(|(pos, _)| *pos);
}
let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
for (idx, msg) in spine.into_iter().enumerate() {
out.push(msg);
if let Some(results) = buckets.remove(&idx) {
for (_, r) in results {
out.push(r);
}
}
}
*messages = out;
}
pub(super) fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
let answered: HashSet<String> = messages
.iter()
.filter(|m| m.role == Role::Tool)
.filter_map(|m| m.tool_call_id.clone())
.collect();
let any_missing = messages.iter().any(|m| {
m.tool_calls()
.iter()
.any(|c| !c.id.is_empty() && !answered.contains(&c.id))
});
if !any_missing {
return;
}
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
for msg in messages.drain(..) {
let synth: Vec<ChatMessage> = msg
.tool_calls()
.iter()
.filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
.map(|c| {
let mut m = ChatMessage::tool_result(
c.id.clone(),
c.function.name.clone(),
"[no tool result recorded — turn interrupted]".to_string(),
);
crate::mark_tool_error(&mut m);
m
})
.collect();
out.push(msg);
out.extend(synth);
}
*messages = out;
}
pub(super) fn is_replay_excluded(msg: &ChatMessage) -> bool {
msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
|| msg
.metadata
.get("pi_exclude_from_context")
.map(String::as_str)
== Some("true")
}
pub(super) fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
if text.is_empty() {
return (Vec::new(), false);
}
let ends_with_newline = text.ends_with('\n');
let body = if ends_with_newline {
&text[..text.len() - 1]
} else {
text
};
(body.split('\n').collect(), ends_with_newline)
}
pub(super) fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
let mut out = lines.join("\n");
if ends_with_newline {
out.push('\n');
}
out
}
pub(super) fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
text.lines().map(str::trim).filter(|l| !l.is_empty())
}
pub(super) fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
let Some(line_end) = out.find('\n') else {
return;
};
let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
return;
};
let Some(object) = record.as_object_mut() else {
return;
};
object.insert(key.to_string(), extension);
out.replace_range(..line_end, &record.to_string());
}
pub(super) fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
messages.truncate(idx);
} else {
messages.clear();
}
if let Some(last) = messages.last_mut() {
last.metadata.remove("__codex_open_turn");
}
}
fn truncate_messages(messages: &mut Vec<ChatMessage>, message_limit: usize) {
truncate_messages_with_anchor(messages, message_limit, Vec::new());
}
pub(super) fn truncate_messages_with_anchor(
messages: &mut Vec<ChatMessage>,
message_limit: usize,
preceding_users: Vec<ChatMessage>,
) {
let limit = message_limit.max(1);
let raw_tail_start = messages.len().saturating_sub(limit);
let anchor_limit = if messages.len() < limit {
limit.saturating_sub(messages.len()).min(2)
} else {
limit.min(2)
};
let mut anchor_candidates = preceding_users;
anchor_candidates.extend(
messages[..raw_tail_start]
.iter()
.filter(|message| message.role == Role::User)
.cloned(),
);
let mut anchors = anchor_candidates
.into_iter()
.rev()
.take(anchor_limit)
.collect::<Vec<_>>();
anchors.reverse();
if messages.len() <= limit && anchors.is_empty() {
return;
}
let tail_count = limit.saturating_sub(anchors.len()).min(messages.len());
let tail_start = messages.len() - tail_count;
let mut selected = Vec::with_capacity(anchors.len() + tail_count);
selected.append(&mut anchors);
selected.extend(messages[tail_start..].iter().cloned());
debug_assert_eq!(selected.len(), limit.min(messages.len() + anchor_limit));
*messages = selected;
}
pub(super) fn truncate_session_messages(session: &mut Session, message_limit: usize) {
truncate_messages(&mut session.messages, message_limit);
}
pub(super) fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
let mut d = 0;
let mut guard = 0;
while let Some(p) = parent_of[i] {
if p == i || guard > parent_of.len() {
break;
}
i = p;
d += 1;
guard += 1;
}
d
}
pub(super) const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
pub(super) fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
if !crate::is_tool_error(message)
&& value
.get(SUPERCODE_TOOL_OUTCOME_KEY)
.and_then(Value::as_str)
== Some("unknown")
{
crate::mark_tool_outcome_unknown(message);
}
}
#[doc(hidden)]
pub fn percent_decode_path(encoded: &str) -> Option<String> {
fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
let bytes = encoded.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] == b'%' {
let high = *bytes.get(index + 1)?;
let low = *bytes.get(index + 2)?;
decoded.push(hex(high)? * 16 + hex(low)?);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded).ok()
}
pub(super) fn push_text(buf: &mut String, v: Option<&Value>) {
if let Some(Value::String(s)) = v {
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(s);
}
}
pub(super) fn extract_tool_result_content(
content: Option<&Value>,
tool_use_result: Option<&Value>,
) -> (String, Vec<Value>) {
let mut parts: Vec<String> = Vec::new();
let mut images: Vec<Value> = Vec::new();
match content {
Some(Value::String(s)) => {
if !s.is_empty() {
parts.push(s.clone());
}
}
Some(Value::Array(items)) => {
for item in items {
match item.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(t) = item.get("text").and_then(Value::as_str) {
parts.push(t.to_string());
}
}
Some("image") => match claude_image_block_to_part(item) {
Some(part) => images.push(part),
None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
},
Some("tool_reference") => {
let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
parts.push(format!("[tool_reference: {name}]"));
}
_ => {
if let Some(s) = item.as_str() {
parts.push(s.to_string());
}
}
}
}
}
Some(other) => parts.push(other.to_string()),
None => {}
}
let joined = parts.join("\n");
if !joined.trim().is_empty() || !images.is_empty() {
return (joined, images);
}
match tool_use_result {
Some(Value::String(s)) => (s.clone(), images),
Some(v) => (v.to_string(), images),
None => (joined, images),
}
}
pub(super) fn extract_text_content(v: Option<&Value>) -> String {
match v {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(items)) => {
let mut parts = Vec::new();
for item in items {
if let Some(t) = item.get("text").and_then(Value::as_str) {
parts.push(t.to_string());
} else if let Some(s) = item.as_str() {
parts.push(s.to_string());
}
}
parts.join("\n")
}
Some(other) => other.to_string(),
None => String::new(),
}
}
pub(super) fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
let Some(Value::Array(items)) = content else {
return Vec::new();
};
items
.iter()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
.filter_map(|item| {
let url = item.get("image_url").and_then(Value::as_str)?;
if url.is_empty() {
return None;
}
Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
})
.collect()
}
pub(super) fn value_to_arg_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
pub(super) fn push_gemini_user_parts(
messages: &mut Vec<ChatMessage>,
content_parts: Vec<Value>,
timestamp: Option<&str>,
source: &Value,
) {
if content_parts.is_empty() {
return;
}
let mut message = ChatMessage {
role: Role::User,
content: None,
content_parts: Some(content_parts),
tool_calls: None,
tool_call_id: None,
name: None,
metadata: Default::default(),
};
if let Some(timestamp) = timestamp {
message
.metadata
.insert("timestamp".into(), timestamp.into());
}
restore_gemini_message_extension(source, &mut message);
messages.push(message);
}
pub(super) fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
ToolCall {
id: id.to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: name.to_string(),
arguments,
},
}
}
pub(super) fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
ChatMessage {
role: Role::Tool,
content: Some(content),
content_parts: None,
tool_calls: None,
tool_call_id: Some(tool_call_id.to_string()),
name: None,
metadata: Default::default(),
}
}
pub(super) fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
let has_text = !text.trim().is_empty();
if !has_text && calls.is_empty() {
return;
}
out.push(ChatMessage {
role: Role::Assistant,
content: has_text.then_some(text),
content_parts: None,
tool_calls: (!calls.is_empty()).then_some(calls),
tool_call_id: None,
name: None,
metadata: Default::default(),
});
}
pub(super) const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
pub(super) const SYNTH_TS_MS: i64 = 1_767_225_600_000;
pub(super) fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
match msg.metadata.get("timestamp") {
Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
_ => SYNTH_TS,
}
}
pub(super) fn synth_uuid(n: usize) -> String {
format!("00000000-0000-4000-8000-{n:012x}")
}
pub(super) fn push_jsonl(out: &mut String, value: &Value) {
out.push_str(&value.to_string());
out.push('\n');
}
pub(super) fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
if let Some(new_id) = new_id {
if let Ok(mut v) = serde_json::from_str::<Value>(line) {
if v.get(key).is_some() {
v[key] = Value::String(new_id.to_string());
out.push_str(&v.to_string());
out.push('\n');
return;
}
}
}
out.push_str(line);
out.push('\n');
}
impl Session {
pub(super) fn cwd_string(&self) -> String {
self.meta
.cwd
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| ".".to_string())
}
pub(super) fn spliced_prefix_lens(&self) -> (usize, usize) {
let message_prefix_len = self
.imported_message_count
.unwrap_or(self.messages.len())
.min(self.messages.len());
let appended_count = self.messages.len() - message_prefix_len;
let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
(raw_prefix_len, message_prefix_len)
}
}