use serde::{Deserialize, Serialize};
pub const SECTION_ENVIRONMENT: &str = "## Environment";
pub const SECTION_USER_CONTEXT: &str = "## User Context";
pub const SECTION_REPRODUCTION: &str = "## Reproduction of dialog";
pub const SECTION_REASONING_TRACE: &str = "## Reasoning Trace";
pub const SECTION_DESCRIPTION: &str = "## Description";
pub const SECTION_ATTACH_MEMORY: &str = "## Attach full memory (optional)";
pub const SECTIONS: [&str; 6] = [
SECTION_ENVIRONMENT,
SECTION_USER_CONTEXT,
SECTION_REPRODUCTION,
SECTION_REASONING_TRACE,
SECTION_DESCRIPTION,
SECTION_ATTACH_MEMORY,
];
pub const COUNT_PLACEHOLDER: &str = "{count}";
pub const TITLE_MAX_LENGTH: usize = 120;
const TITLE_JOIN: &str = "` + `";
const TURN_SEPARATOR: &str = ": ";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ReportField {
pub label: String,
pub value: String,
}
impl ReportField {
#[must_use]
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ReportTurn {
pub role: String,
pub content: String,
pub intent: String,
pub reported: bool,
pub report_invoking: bool,
}
impl ReportTurn {
#[must_use]
pub fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: role.into(),
content: content.into(),
..Self::default()
}
}
fn prefix(&self) -> &'static str {
if self.role.eq_ignore_ascii_case("user") {
"U"
} else if self.role.eq_ignore_ascii_case("tool") {
"T"
} else {
"A"
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ReportAttachment {
pub heading: String,
pub note: String,
pub language: String,
pub content: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ReportLabels {
pub legend: String,
pub no_messages: String,
pub omitted_earlier: String,
pub omitted_earlier_one: String,
pub omitted_records: String,
pub trace_heading: String,
pub description_placeholder: String,
pub memory_note: String,
}
impl ReportLabels {
#[must_use]
pub fn from_seed() -> Self {
let mut info = crate::seed::agent_info();
let mut take = |key: &str| info.remove(key).unwrap_or_default();
Self {
legend: take("issue_report_dialog_legend"),
no_messages: take("issue_report_no_messages"),
omitted_earlier: take("issue_report_omitted_messages"),
omitted_earlier_one: take("issue_report_omitted_message"),
omitted_records: take("issue_report_omitted_records"),
trace_heading: take("issue_report_trace_heading"),
description_placeholder: take("issue_report_description_placeholder"),
memory_note: take("issue_report_memory_note"),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ReportBody {
pub labels: ReportLabels,
pub environment: Vec<ReportField>,
pub user_context: Vec<ReportField>,
pub turns: Vec<ReportTurn>,
pub earlier_omitted: usize,
pub reasoning_trace: Vec<String>,
pub attachments: Vec<ReportAttachment>,
}
impl ReportBody {
#[must_use]
pub fn render(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push(SECTION_ENVIRONMENT.to_owned());
lines.push(String::new());
push_fields(&mut lines, &self.environment);
lines.push(String::new());
if self
.user_context
.iter()
.any(|field| !field.value.is_empty())
{
lines.push(SECTION_USER_CONTEXT.to_owned());
lines.push(String::new());
push_fields(&mut lines, &self.user_context);
lines.push(String::new());
}
lines.push(SECTION_REPRODUCTION.to_owned());
lines.push(String::new());
self.push_dialog(&mut lines);
if self.earlier_omitted == 0 && !self.reasoning_trace.is_empty() {
lines.push(String::new());
lines.push(SECTION_REASONING_TRACE.to_owned());
lines.push(String::new());
lines.push(self.labels.trace_heading.clone());
lines.push(String::new());
push_code_block(&mut lines, "", &self.reasoning_trace.join("\n"));
lines.push(String::new());
}
lines.push(String::new());
lines.push(SECTION_DESCRIPTION.to_owned());
lines.push(String::new());
lines.push(self.labels.description_placeholder.clone());
lines.push(String::new());
lines.push(SECTION_ATTACH_MEMORY.to_owned());
lines.push(String::new());
lines.push(self.labels.memory_note.clone());
lines.push(String::new());
for attachment in &self.attachments {
lines.push(attachment.heading.clone());
lines.push(String::new());
if !attachment.note.is_empty() {
lines.push(attachment.note.clone());
lines.push(String::new());
}
push_code_block(&mut lines, &attachment.language, &attachment.content);
lines.push(String::new());
}
lines.join("\n")
}
fn push_dialog(&self, lines: &mut Vec<String>) {
if self.turns.is_empty() {
lines.push(self.labels.no_messages.clone());
return;
}
lines.push(self.labels.legend.clone());
lines.push(String::new());
let fence = pick_fence(&self.turns.iter().map(|turn| turn.content.as_str()));
lines.push(fence.clone());
if self.earlier_omitted > 0 {
let label = if self.earlier_omitted == 1 && !self.labels.omitted_earlier_one.is_empty()
{
&self.labels.omitted_earlier_one
} else {
&self.labels.omitted_earlier
};
lines.push(render_count(label, self.earlier_omitted));
}
for turn in &self.turns {
let mut annotations: Vec<String> = Vec::new();
if turn.intent == "unknown" {
annotations.push(format!("intent: {}", turn.intent));
}
if turn.reported {
if !turn.intent.is_empty() && turn.intent != "unknown" {
annotations.push(format!("intent: {}", turn.intent));
}
annotations.push(String::from("reported"));
}
let head = if annotations.is_empty() {
turn.prefix().to_owned()
} else {
format!("{} ({})", turn.prefix(), annotations.join(", "))
};
let mut rows = turn.content.split('\n');
let first = rows.next().unwrap_or_default();
lines.push(format!("{head}{TURN_SEPARATOR}{first}"));
for row in rows {
lines.push(format!(" {row}"));
}
}
lines.push(fence);
}
}
fn push_fields(lines: &mut Vec<String>, fields: &[ReportField]) {
for field in fields.iter().filter(|field| !field.value.is_empty()) {
lines.push(format!("- **{}**: {}", field.label, field.value));
}
}
fn push_code_block(lines: &mut Vec<String>, language: &str, content: &str) {
let fence = pick_fence(&std::iter::once(content));
lines.push(format!("{fence}{language}"));
lines.push(content.to_owned());
lines.push(fence);
}
fn pick_fence<'a>(samples: &(impl Iterator<Item = &'a str> + Clone)) -> String {
let mut fence = String::from("```");
while samples.clone().any(|sample| sample.contains(&fence)) {
fence.push('`');
}
fence
}
#[must_use]
pub fn render_count(label: &str, count: usize) -> String {
label.replace(COUNT_PLACEHOLDER, &count.to_string())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TitleSettings {
pub prefix: String,
pub default_title: String,
}
impl TitleSettings {
#[must_use]
pub fn from_seed() -> Self {
let mut info = crate::seed::agent_info();
Self {
prefix: info.remove("issue_report_title_prefix").unwrap_or_default(),
default_title: info
.remove("issue_report_default_title")
.unwrap_or_default(),
}
}
}
#[must_use]
pub fn issue_title(turns: &[ReportTurn], settings: &TitleSettings) -> String {
let subjects = title_subjects(turns);
let Some(first) = subjects.first() else {
return settings.default_title.clone();
};
if let Some(last) = subjects.last().filter(|last| *last != first) {
let combined = format!("{}`{first}{TITLE_JOIN}{last}`", settings.prefix);
if combined.chars().count() <= TITLE_MAX_LENGTH {
return combined;
}
}
let budget = TITLE_MAX_LENGTH.saturating_sub(settings.prefix.chars().count() + 2);
format!("{}`{}`", settings.prefix, truncate_words(first, budget))
}
fn title_subjects(turns: &[ReportTurn]) -> Vec<String> {
let mut subjects: Vec<(String, bool)> = turns
.iter()
.filter(|turn| turn.role.eq_ignore_ascii_case("user"))
.map(|turn| (normalize_single_line(&turn.content), turn.report_invoking))
.filter(|(text, _)| !text.is_empty())
.collect();
while subjects.len() > 1 && subjects.last().is_some_and(|(_, invoking)| *invoking) {
subjects.pop();
}
let mut subjects: Vec<String> = subjects.into_iter().map(|(text, _)| text).collect();
subjects.dedup();
subjects
}
fn normalize_single_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[must_use]
pub fn truncate_words(text: &str, max: usize) -> String {
let text = text.trim();
if text.chars().count() <= max {
return text.to_owned();
}
let head: Vec<char> = text.chars().take(max.saturating_sub(1)).collect();
let boundary = head.iter().rposition(|character| character.is_whitespace());
let cut: String = match boundary {
Some(index) if index >= max / 2 => head[..index].iter().collect(),
_ => head.iter().collect(),
};
format!("{}…", cut.trim_end())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TruncatedRecords {
pub text: String,
pub omitted: usize,
}
#[must_use]
pub fn truncate_records(text: &str, max_bytes: usize, omitted_label: &str) -> TruncatedRecords {
if text.len() <= max_bytes {
return TruncatedRecords {
text: text.to_owned(),
omitted: 0,
};
}
let lines: Vec<&str> = text.lines().collect();
let Some(run) = longest_record_run(&lines) else {
return truncate_lines(&lines, max_bytes, omitted_label);
};
let indent = " ".repeat(run.indent);
let surrounding =
joined_len(&lines[..run.start]) + joined_len(&lines[run.end..]) + indent.len();
let budget = max_bytes.saturating_sub(surrounding + omitted_label.len() + 1);
let sizes: Vec<usize> = (0..run.len())
.map(|record| joined_len(&lines[run.record(record)]))
.collect();
let (mut head_count, mut tail_count, mut used) = (0, 0, 0);
while head_count < sizes.len() && used + sizes[head_count] <= budget / 2 {
used += sizes[head_count];
head_count += 1;
}
while head_count + tail_count < sizes.len()
&& used + sizes[sizes.len() - 1 - tail_count] <= budget
{
used += sizes[sizes.len() - 1 - tail_count];
tail_count += 1;
}
let omitted = sizes.len() - head_count - tail_count;
if omitted == 0 {
return truncate_lines(&lines, max_bytes, omitted_label);
}
let marker = format!("{indent}{}", render_count(omitted_label, omitted));
let mut kept: Vec<&str> = lines[..run.start].to_vec();
for record in 0..head_count {
kept.extend_from_slice(&lines[run.record(record)]);
}
kept.push(&marker);
for record in sizes.len() - tail_count..sizes.len() {
kept.extend_from_slice(&lines[run.record(record)]);
}
kept.extend_from_slice(&lines[run.end..]);
let truncated = format!("{}\n", kept.join("\n"));
if truncated.len() > max_bytes {
return truncate_lines(&lines, max_bytes, omitted_label);
}
TruncatedRecords {
text: truncated,
omitted,
}
}
struct RecordRun {
indent: usize,
start: usize,
end: usize,
starts: Vec<usize>,
}
impl RecordRun {
const fn len(&self) -> usize {
self.starts.len()
}
fn record(&self, index: usize) -> std::ops::Range<usize> {
self.starts[index]..self.starts.get(index + 1).copied().unwrap_or(self.end)
}
}
fn longest_record_run(lines: &[&str]) -> Option<RecordRun> {
let mut indents: Vec<usize> = lines
.iter()
.filter(|line| !line.trim().is_empty())
.map(|line| indent_of(line))
.collect();
indents.sort_unstable();
indents.dedup();
let mut best: Option<(usize, RecordRun)> = None;
for indent in indents {
for run in runs_at(lines, indent) {
let size = joined_len(&lines[run.start..run.end]);
if best.as_ref().is_none_or(|(largest, _)| *largest < size) {
best = Some((size, run));
}
}
}
best.map(|(_, run)| run)
}
fn runs_at(lines: &[&str], indent: usize) -> Vec<RecordRun> {
let mut runs = Vec::new();
let mut starts: Vec<usize> = Vec::new();
let mut head = "";
for (index, line) in lines.iter().enumerate() {
if line.trim().is_empty() || indent_of(line) > indent {
continue;
}
let token = head_token(line);
let continues = indent_of(line) == indent && !starts.is_empty() && token == head;
if !continues {
close_run(&mut runs, &mut starts, indent, index);
head = token;
}
if indent_of(line) == indent {
starts.push(index);
}
}
close_run(&mut runs, &mut starts, indent, lines.len());
runs
}
fn close_run(runs: &mut Vec<RecordRun>, starts: &mut Vec<usize>, indent: usize, end: usize) {
let starts = std::mem::take(starts);
if starts.len() < 2 {
return;
}
runs.push(RecordRun {
indent,
start: starts[0],
end,
starts,
});
}
fn head_token(line: &str) -> &str {
line.split_whitespace().next().unwrap_or("")
}
fn truncate_lines(lines: &[&str], max_bytes: usize, omitted_label: &str) -> TruncatedRecords {
let mut kept: Vec<&str> = Vec::new();
let mut used = omitted_label.len() + 1;
for line in lines {
if used + line.len() + 1 > max_bytes {
break;
}
used += line.len() + 1;
kept.push(line);
}
let omitted = lines.len() - kept.len();
let mut text = kept.join("\n");
if omitted > 0 {
text.push('\n');
text.push_str(&render_count(omitted_label, omitted));
}
text.push('\n');
TruncatedRecords { text, omitted }
}
fn indent_of(line: &str) -> usize {
line.len() - line.trim_start_matches(' ').len()
}
fn joined_len(lines: &[&str]) -> usize {
lines.iter().map(|line| line.len() + 1).sum()
}