use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::paths;
use crate::store::Store;
use crate::types::Task;
use super::show_output_extract::collect_messages;
pub fn output_text_for_task(store: &Store, task_id: &str, full: bool) -> Result<String> {
let task = load_task_for_output(task_id, store)?;
render_task_output(&task, task_id, full, 200)
}
fn load_task_for_output(task_id: &str, store: &Store) -> Result<Task> {
store
.get_task(task_id)?
.ok_or_else(|| anyhow::anyhow!("Task '{task_id}' not found"))
}
pub fn output_text(store: &Arc<Store>, task_id: &str) -> Result<String> {
let task = super::super::load_task(store, task_id)?;
render_task_output(&task, task_id, true, 200)
}
pub fn output_text_brief(store: &Arc<Store>, task_id: &str) -> Result<String> {
let task = super::super::load_task(store, task_id)?;
render_task_output(&task, task_id, false, 50)
}
fn render_task_output(task: &Task, task_id: &str, full: bool, tail_lines: usize) -> Result<String> {
if let Ok(content) = read_task_output(task) {
return Ok(content);
}
if !full && is_research_task(task) {
let path = task_log_path(task, task_id);
if let Some(content) = extract_messages_research(&path) {
return Ok(content);
}
}
if let Some(content) = extract_messages_for_task(task, task_id, full) {
return Ok(content);
}
let path = task_log_path(task, task_id);
Ok(read_tail(&path, tail_lines, "No output or log available"))
}
fn task_log_path(task: &Task, task_id: &str) -> PathBuf {
task.log_path
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| paths::log_path(task_id))
}
fn is_research_task(task: &Task) -> bool {
task.worktree_path.is_none() && task.worktree_branch.is_none()
}
pub(crate) const UNRECOGNIZED_JSON_NOTICE_PREFIX: &str = "[Unrecognized JSON log format";
pub(crate) fn is_unrecognized_json_notice(text: &str) -> bool {
text.trim().starts_with(UNRECOGNIZED_JSON_NOTICE_PREFIX)
}
fn extract_messages_for_task(task: &Task, task_id: &str, full: bool) -> Option<String> {
extract_messages_from_log(&task_log_path(task, task_id), full, Some(task.agent_display_name()))
}
pub(crate) fn extract_messages_from_log(
log_path: &Path,
full: bool,
agent_name: Option<&str>,
) -> Option<String> {
const MAX_MESSAGE_CHARS: usize = 1_000;
const MAX_OUTPUT_CHARS: usize = 8_000;
const HEAD_MESSAGE_COUNT: usize = 3;
const TAIL_MESSAGE_COUNT: usize = 7;
let content = std::fs::read_to_string(log_path).ok()?;
let mut messages = collect_messages(&content);
if messages.is_empty() {
if let Some(notice) = unrecognized_json_log_notice(&content, log_path, agent_name) {
return Some(notice);
}
return None;
}
if !full {
truncate_messages(&mut messages, MAX_MESSAGE_CHARS);
messages = cap_message_count(messages, HEAD_MESSAGE_COUNT, TAIL_MESSAGE_COUNT);
}
Some(join_messages(messages, full, MAX_OUTPUT_CHARS))
}
pub(crate) fn unrecognized_json_log_notice(
content: &str,
log_path: &Path,
agent_name: Option<&str>,
) -> Option<String> {
let mut total_lines = 0;
let mut json_lines = 0;
let mut first_sample: Option<String> = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || is_non_output_line(trimmed) {
continue;
}
total_lines += 1;
if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
json_lines += 1;
if first_sample.is_none() {
first_sample = Some(trimmed.to_string());
}
}
}
if total_lines > 0 && json_lines == total_lines {
let sample = first_sample.unwrap_or_default();
let sample_truncated = if sample.chars().count() > 120 {
format!("{}…", sample.chars().take(120).collect::<String>())
} else {
sample
};
let agent = agent_name.unwrap_or("unknown agent");
Some(format!(
"{UNRECOGNIZED_JSON_NOTICE_PREFIX} from {agent}]\nSample line: {sample_truncated}\nSee transcript at {}",
log_path.display()
))
} else {
None
}
}
pub(crate) fn is_aid_sentinel_line(line: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with("=== AID TASK") || trimmed.starts_with("=== AID")
}
pub(crate) fn is_agent_runner_warning_line(line: &str) -> bool {
line.trim().starts_with("Warning: Reached maximum")
}
pub(crate) fn is_non_output_line(line: &str) -> bool {
is_aid_sentinel_line(line) || is_agent_runner_warning_line(line)
}
pub(crate) fn extract_messages_research(log_path: &Path) -> Option<String> {
const MAX_MESSAGE_CHARS: usize = 4_000;
const MAX_OUTPUT_CHARS: usize = 20_000;
let content = std::fs::read_to_string(log_path).ok()?;
let mut messages = collect_messages(&content);
if messages.is_empty() {
return None;
}
truncate_messages(&mut messages, MAX_MESSAGE_CHARS);
Some(join_messages(messages, false, MAX_OUTPUT_CHARS))
}
fn truncate_messages(messages: &mut [String], max_chars: usize) {
for message in messages {
if message.len() > max_chars {
message.truncate(message.floor_char_boundary(max_chars.saturating_sub(3)));
message.push_str("...");
}
}
}
fn cap_message_count(messages: Vec<String>, head: usize, tail: usize) -> Vec<String> {
if messages.len() <= head + tail {
return messages;
}
let omitted = messages.len() - head - tail;
let mut capped = Vec::with_capacity(head + tail + 1);
capped.extend(messages[..head].iter().cloned());
capped.push(format!("[... {omitted} messages omitted ...]"));
capped.extend(messages[messages.len() - tail..].iter().cloned());
capped
}
fn join_messages(messages: Vec<String>, full: bool, max_output_chars: usize) -> String {
let mut output = messages.join("\n---\n");
if !full && output.len() > max_output_chars {
output.truncate(output.floor_char_boundary(max_output_chars.saturating_sub(3)));
output.push_str("...");
}
output
}
pub fn read_task_output(task: &Task) -> Result<String> {
if let Some(path) = task.output_path.as_deref() {
if let Ok(content) = std::fs::read_to_string(path) {
if is_valid_output_content(&content) {
return Ok(content);
}
}
}
let persisted = paths::task_dir(task.id.as_str()).join("result.md");
if persisted.exists() {
if let Ok(content) = std::fs::read_to_string(&persisted) {
if is_valid_output_content(&content) {
return Ok(content);
}
}
}
Err(anyhow::anyhow!("Task has no output file"))
}
fn is_valid_output_content(content: &str) -> bool {
content
.lines()
.any(|line| !line.trim().is_empty() && !is_non_output_line(line))
}
pub fn log_text(task_id: &str) -> Result<String> {
let path = paths::log_path(task_id);
std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read log file {}", path.display()))
}
pub fn log_text_brief(task_id: &str) -> Result<String> {
let path = paths::log_path(task_id);
Ok(read_tail(&path, 200, "No log available"))
}
pub(crate) fn read_tail(path: &Path, limit: usize, unavailable: &str) -> String {
let Ok(bytes) = std::fs::read(path) else {
return unavailable.to_string();
};
let content = String::from_utf8_lossy(&bytes);
let tail = tail_lines(&content, limit);
if tail.is_empty() {
unavailable.to_string()
} else {
tail
}
}
pub(crate) fn tail_lines(content: &str, limit: usize) -> String {
content
.lines()
.rev()
.take(limit)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod notice_delivery_tests {
use super::unrecognized_json_log_notice;
#[test]
fn an_unparsed_stream_produces_a_notice_the_delivery_guard_accepts() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("t-x.jsonl");
std::fs::write(
&log,
"{\"type\":\"event\",\"event\":{\"type\":\"unknown_shape\"}}\n\
=== AID TASK t-x DONE (exit 0) ===\n",
)
.unwrap();
let notice = unrecognized_json_log_notice(
&std::fs::read_to_string(&log).unwrap(),
&log,
Some("commandcode"),
)
.expect("an all-JSON stream with no recognised arm must produce a notice");
assert!(notice.contains("commandcode"), "notice must name the agent: {notice}");
assert!(
crate::delivery_guard::looks_like_delivered_report(¬ice),
"the guard must not read aid's own parse failure as a missing delivery"
);
assert!(
!crate::delivery_guard::looks_like_delivered_report(
"First, I will read the file. Next, I will check the tests."
),
"narration must still be refused"
);
}
}