use crate::util::{parse_harness, resolve, short_id, usage};
use anyhow::{Context, Result};
use cv_core::ir::Block;
use cv_core::ParseOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
pub(crate) fn cmd_cat(session: &str, tool_use_id: &str, harness: Option<String>, input: bool) -> Result<()> {
let want = parse_harness(&harness)?;
let (r, adapter) = resolve(session, want)?;
let mut found = First {
id: tool_use_id,
input,
hit: None,
};
let mut parsed = cv_core::stream::stream_counted(
adapter.as_ref(),
&r,
&ParseOptions::lazy(),
&mut found,
cv_core::stream::Pass::RECORD,
)?
.session;
parsed.messages = found.hit.into_iter().collect();
let sidecar = sidecar_path(&r.path);
let mut out = std::io::BufWriter::new(std::io::stdout().lock());
if input {
for m in &parsed.messages {
for b in &m.content {
if let Block::ToolUse { id, name, input, .. } = b {
if id == tool_use_id {
eprintln!("✦ {name} ({id}) — input");
writeln!(out, "{}", serde_json::to_string_pretty(input)?)?;
return Ok(());
}
}
}
}
return usage(format!(
"no tool call {tool_use_id:?} in {} — `cv tools {} --timeline` lists its tool calls",
short_id(&r.id),
short_id(&r.id)
));
}
print_result(&parsed, &r.id, tool_use_id, &sidecar, &mut out)
}
fn print_result(
parsed: &cv_core::Session,
session_id: &str,
tool_use_id: &str,
sidecar: &Path,
mut out: &mut impl Write,
) -> Result<()> {
let resolver = parsed.resolver();
for m in &parsed.messages {
for b in &m.content {
let Block::ToolResult {
tool_use_id: tid,
content,
details,
tool_name,
..
} = b
else {
continue;
};
if tid != tool_use_id {
continue;
}
if let Some(p) = details
.as_ref()
.and_then(|d| d.pointer("/persistedOutput/path"))
.and_then(|v| v.as_str())
{
let path = Path::new(p);
if path.is_file() {
eprintln!("✦ persisted output: {p}");
let mut f = std::fs::File::open(path).with_context(|| format!("opening {p}"))?;
std::io::copy(&mut f, &mut out)?;
out.flush()?;
return Ok(());
}
eprintln!("⚠ persisted output {p} is missing — printing the transcript's stub instead");
}
let text = content
.try_resolve(&resolver)
.context("reading transcript tool result")?;
if text.trim_start().starts_with(cv_core::prune::MARKER_PREFIX) {
if sidecar.is_file() {
if let Ok(v) = cv_core::prune::retrieve(sidecar, tool_use_id) {
eprintln!("✦ from prune sidecar {}", sidecar.display());
return print_value(&mut out, &v);
}
}
eprintln!(
"⚠ {} was pruned but no sidecar holds it (hard-dropped with `--drop`?) — printing the marker",
tool_use_id
);
}
if let Some(name) = tool_name {
eprintln!("✦ {name} ({tool_use_id})");
}
writeln!(out, "{text}")?;
out.flush()?;
return Ok(());
}
}
if sidecar.is_file() {
if let Ok(v) = cv_core::prune::retrieve(sidecar, tool_use_id) {
eprintln!("✦ from prune sidecar {}", sidecar.display());
return print_value(&mut out, &v);
}
}
usage(format!(
"no tool result {tool_use_id:?} in {} — `cv tools {} --timeline` lists its tool calls",
short_id(session_id),
short_id(session_id)
))
}
struct First<'a> {
id: &'a str,
input: bool,
hit: Option<cv_core::Message>,
}
impl cv_core::MessageSink for First<'_> {
fn message(&mut self, m: cv_core::Message) -> cv_core::Flow {
let wanted = m.content.iter().any(|b| match b {
Block::ToolUse { id, .. } => self.input && id == self.id,
Block::ToolResult { tool_use_id, .. } => !self.input && tool_use_id == self.id,
_ => false,
});
if !wanted {
return cv_core::Flow::Continue;
}
self.hit = Some(m);
cv_core::Flow::Stop
}
}
fn sidecar_path(transcript: &Path) -> PathBuf {
let stem = transcript.file_stem().and_then(|s| s.to_str()).unwrap_or("session");
transcript
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."))
.join(format!("{stem}.flat.jsonl"))
}
fn print_value(out: &mut impl Write, v: &serde_json::Value) -> Result<()> {
match v.as_str() {
Some(s) => writeln!(out, "{s}")?,
None => writeln!(out, "{}", serde_json::to_string_pretty(v)?)?,
}
out.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retained_result_prints_exact_text_and_rejects_source_failure_without_output() {
use cv_core::{Block, Flow, Harness, Message, MessageSink, Role, Session};
let world = crate::test_world("cat-retained-result");
let path = world.base.join("result.json");
let body = "actual🌙\n\"\\".repeat(1024);
let wire = serde_json::to_vec(&body).unwrap();
std::fs::write(&path, &wire).unwrap();
let content = cv_core::Json::source(&path, 0, wire.len() as u64)
.unwrap()
.with_view(|view| view.source_text())
.unwrap()
.unwrap();
assert!(matches!(content, cv_core::Text::Source(_)));
let mut message = Message::new(Role::Tool);
message.content.push(Block::ToolResult {
tool_use_id: "call".into(),
content,
is_error: false,
tool_name: None,
status: None,
details: None,
});
let mut first = First {
id: "call",
input: false,
hit: None,
};
assert_eq!(first.message(message), Flow::Stop);
let mut session = Session::new("cat", Harness::Codex);
session.messages = first.hit.into_iter().collect();
let sidecar = world.base.join("absent.flat.jsonl");
let mut output = Vec::new();
print_result(&session, &session.id, "call", &sidecar, &mut output).unwrap();
assert_eq!(output, format!("{body}\n").as_bytes());
let mut owned = session.clone();
let resolver = owned.resolver();
owned.messages[0].materialize(&resolver).unwrap();
let mut expected = Vec::new();
print_result(&owned, &owned.id, "call", &sidecar, &mut expected).unwrap();
assert_eq!(output, expected);
std::fs::remove_file(path).unwrap();
let mut rejected = Vec::new();
assert!(print_result(&session, &session.id, "call", &sidecar, &mut rejected).is_err());
assert!(rejected.is_empty());
}
#[test]
fn sidecar_sits_next_to_the_transcript() {
assert_eq!(
sidecar_path(Path::new("/p/-work/abc.jsonl")),
PathBuf::from("/p/-work/abc.flat.jsonl")
);
assert_eq!(sidecar_path(Path::new("abc.jsonl")), PathBuf::from("./abc.flat.jsonl"));
}
}