Skip to main content

mj_transcript/
summary.rs

1//! A deterministic model-facing view of history. Source transcripts stay lossless.
2use agent_client_protocol::schema::v1::{ContentBlock, SessionUpdate, ToolCall};
3use mj_core::archive::{CanonicalSessionSnapshot, CanonicalTranscriptBody};
4use mj_core::state::{MaterializedSession, TranscriptBody};
5use mj_core::transcript::{
6    ToolCallPresentation, materialized_chunks_text, materialized_content_text,
7};
8use serde_json::{Value, json};
9
10use crate::transcript::materialized_tool_call_presentation;
11
12/// Opening sentence of every handoff this module writes. Generation and
13/// detection share it so a later resume can always recognize its own prior
14/// handoff turns.
15pub const HANDOFF_PREAMBLE: &str =
16    "You are continuing a coding session previously run by another ACP harness.";
17/// Opening sentence of a hand-off written when an archived session is restored
18/// from the SessionWiki index. The restored session has no workspace from the
19/// old one, so it is marked apart from a cross-harness resume.
20pub const ARCHIVE_HANDOFF_PREAMBLE: &str = "Archived session restored from SessionWiki.";
21/// Opening sentence of the byte-truncating handoff this pipeline replaced.
22/// Sessions resumed by that build still carry it in their transcripts.
23pub const LEGACY_HANDOFF_PREAMBLE: &str =
24    "Continue this coding session from the portable transcript below.";
25/// What a prior handoff turn contributes to a new compaction. The transcript
26/// already carries the pre-resume lineage as ordinary turns, so repeating the
27/// handoff body would only spend budget on a summary of a summary.
28pub const HANDOFF_PLACEHOLDER: &str =
29    "[cross-harness resume handoff: continuing work from a prior harness]";
30/// Prior handoffs repeat history already represented in the lineage.
31fn user_text(text: String) -> String {
32    let trimmed = text.trim_start();
33    if [
34        HANDOFF_PREAMBLE,
35        ARCHIVE_HANDOFF_PREAMBLE,
36        LEGACY_HANDOFF_PREAMBLE,
37    ]
38    .iter()
39    .any(|p| trimmed.starts_with(p))
40    {
41        HANDOFF_PLACEHOLDER.into()
42    } else {
43        text
44    }
45}
46
47pub const FULL_TOOL_CALLS: usize = 8;
48/// Change when derived indexing content changes, even without a source update.
49pub const SUMMARY_VERSION: u32 = 1;
50pub const DEFAULT_SUMMARY_BYTES: usize = 256 * 1024;
51const LIVE_ITEMS: usize = 512;
52const LIVE_ITEM_BYTES: usize = 64 * 1024;
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum SummaryRole {
56    User,
57    Assistant,
58    Tool,
59    Plan,
60}
61
62#[derive(Debug, Clone)]
63pub struct SummaryEntry {
64    pub id: String,
65    pub position: u64,
66    pub created_at_ms: i64,
67    pub role: SummaryRole,
68    pub text: String,
69    /// Full retained calls or a compact, outcome-only object. Never a presentation sidecar.
70    pub tool: Option<Value>,
71    terminal_refs: Vec<String>,
72}
73
74impl SummaryEntry {
75    pub fn body(&self) -> String {
76        self.tool
77            .as_ref()
78            .map_or_else(|| self.text.clone(), Value::to_string)
79    }
80
81    fn render(&self, body_limit: usize) -> String {
82        let role = match self.role {
83            SummaryRole::User => "user",
84            SummaryRole::Assistant => "assistant",
85            SummaryRole::Tool => "tool",
86            SummaryRole::Plan => "plan",
87        };
88        let body = self.body();
89        // Names and outcomes are outside the potentially large raw-body excerpt.
90        let label = if self.role == SummaryRole::Tool {
91            format!(" {}", self.text)
92        } else {
93            String::new()
94        };
95        format!(
96            "<{role}{label}>\n{}\n</{role}>\n",
97            excerpt(&body, body_limit)
98        )
99    }
100}
101
102#[derive(Debug, Default, Clone)]
103pub struct TranscriptSummary {
104    pub entries: Vec<SummaryEntry>,
105    next_position: u64,
106    open_message: Option<String>,
107    leading_omitted: bool,
108}
109
110/// A head/tail excerpt with an explicit byte omission count, never broken UTF-8.
111pub fn excerpt(text: &str, limit: usize) -> String {
112    if text.len() <= limit {
113        return text.to_owned();
114    }
115    let marker_budget = format!("\n[{} bytes omitted]\n", text.len()).len();
116    if limit < marker_budget {
117        return "[omitted]".chars().take(limit).collect();
118    }
119    let available = limit - marker_budget;
120    let mut head = available / 2;
121    while !text.is_char_boundary(head) {
122        head -= 1;
123    }
124    let mut tail = text.len() - (available - available / 2);
125    while !text.is_char_boundary(tail) {
126        tail += 1;
127    }
128    format!(
129        "{}\n[{} bytes omitted]\n{}",
130        &text[..head],
131        tail - head,
132        &text[tail..]
133    )
134}
135
136fn tool_value(call: &Value, presentation: Option<&ToolCallPresentation>, full: bool) -> Value {
137    let name = match serde_json::from_value::<ToolCall>(call.clone()) {
138        Ok(call) => materialized_tool_call_presentation(presentation, &call).summary,
139        Err(error) => {
140            tracing::warn!(%error, "could not decode tool for transcript summary");
141            "[invalid tool call]".into()
142        }
143    };
144    let mut compact = json!({
145        "toolCallId": call.get("toolCallId").and_then(Value::as_str).unwrap_or("unknown"),
146        "name": excerpt(&name, 256),
147        "kind": call.get("kind").and_then(Value::as_str).unwrap_or("other"),
148        "status": call.get("status").and_then(Value::as_str).unwrap_or("pending"),
149    });
150    if let Some(code) = call.pointer("/rawOutput/exit_code").and_then(Value::as_i64) {
151        compact["exit_code"] = json!(code);
152    }
153    if let Some(signal) = call.pointer("/rawOutput/signal").and_then(Value::as_str) {
154        compact["signal"] = json!(signal);
155    }
156    if full {
157        compact["call"] = call.clone();
158    }
159    compact
160}
161
162/// Reconstruct indexed summary content without treating serialized detail as a tool name.
163/// Legacy indexes contain only titles and have no recoverable tool payload.
164pub fn indexed_tool_call(
165    text: &str,
166    fallback_id: &str,
167) -> (Value, Vec<mj_core::archive::CanonicalTerminalOutput>) {
168    if let Ok(value) = serde_json::from_str::<Value>(text)
169        && value.get("name").is_some_and(Value::is_string)
170        && value.get("status").is_some_and(Value::is_string)
171        && value.get("toolCallId").is_some_and(Value::is_string)
172    {
173        if let Some(call) = value.get("call")
174            && serde_json::from_value::<ToolCall>(call.clone()).is_ok()
175        {
176            let mut terminals = Vec::new();
177            if let Some(records) = value.get("terminals").and_then(Value::as_object) {
178                for (id, record) in records {
179                    let mut record = record.clone();
180                    if !record.is_object() {
181                        tracing::warn!("invalid indexed terminal summary");
182                        continue;
183                    }
184                    record["terminal_id"] = json!(id);
185                    match serde_json::from_value(record) {
186                        Ok(record) => terminals.push(record),
187                        Err(error) => tracing::warn!(%error, "invalid indexed terminal summary"),
188                    }
189                }
190            }
191            return (call.clone(), terminals);
192        }
193        let mut call = json!({"toolCallId":value["toolCallId"],"title":value["name"],"status":value["status"],"kind":value.get("kind").cloned().unwrap_or(json!("other"))});
194        for key in ["exit_code", "signal"] {
195            if let Some(outcome) = value.get(key) {
196                if !call["rawOutput"].is_object() {
197                    call["rawOutput"] = json!({});
198                }
199                call["rawOutput"][key] = outcome.clone();
200            }
201        }
202        return (call, Vec::new());
203    }
204    (
205        json!({"toolCallId":fallback_id,"title":text,"status":"completed"}),
206        Vec::new(),
207    )
208}
209
210fn tool_label(value: &Value) -> String {
211    let mut label = format!(
212        "{} [{}]",
213        value["name"].as_str().unwrap_or("tool"),
214        value["status"].as_str().unwrap_or("unknown")
215    );
216    if let Some(code) = value.get("exit_code") {
217        label.push_str(&format!(" exit={code}"));
218    }
219    if let Some(signal) = value.get("signal").and_then(Value::as_str) {
220        label.push_str(&format!(" signal={signal}"));
221    }
222    label
223}
224
225impl TranscriptSummary {
226    pub fn from_snapshot(snapshot: &CanonicalSessionSnapshot) -> Self {
227        let mut summary = Self::default();
228        let start = snapshot.current_context_start();
229        let cutoff = snapshot
230            .transcript
231            .iter()
232            .filter(|i| start == 0 || i.position > start)
233            .filter(|i| matches!(i.body, CanonicalTranscriptBody::Tool { .. }))
234            .rev()
235            .take(FULL_TOOL_CALLS)
236            .map(|i| i.position)
237            .min()
238            .unwrap_or(u64::MAX);
239        for item in snapshot
240            .transcript
241            .iter()
242            .filter(|i| start == 0 || i.position > start)
243        {
244            let (role, text, tool) = match &item.body {
245                CanonicalTranscriptBody::User { content } => (
246                    SummaryRole::User,
247                    user_text(materialized_content_text(content)),
248                    None,
249                ),
250                CanonicalTranscriptBody::Agent { chunks, .. } => (
251                    SummaryRole::Assistant,
252                    materialized_chunks_text(chunks),
253                    None,
254                ),
255                CanonicalTranscriptBody::Tool {
256                    call,
257                    terminal_outputs,
258                    presentation,
259                    ..
260                } => {
261                    let mut value =
262                        tool_value(call, presentation.as_ref(), item.position >= cutoff);
263                    attach_terminals(
264                        &mut value,
265                        terminal_outputs.iter().map(|t| {
266                            (
267                                &t.terminal_id,
268                                &t.output,
269                                t.exit_code,
270                                t.signal.as_deref(),
271                                t.truncated,
272                            )
273                        }),
274                    );
275                    (SummaryRole::Tool, tool_label(&value), Some(value))
276                }
277                CanonicalTranscriptBody::Plan { plan } => {
278                    (SummaryRole::Plan, plan.to_string(), None)
279                }
280                _ => continue,
281            };
282            summary.entries.push(SummaryEntry {
283                id: item.stable_id.clone(),
284                position: item.position,
285                created_at_ms: item.created_at_ms,
286                role,
287                text,
288                terminal_refs: tool
289                    .as_ref()
290                    .and_then(|v| v.get("call"))
291                    .map(crate::projection::tool_call_terminal_ids)
292                    .unwrap_or_default(),
293                tool,
294            });
295        }
296        summary.next_position = snapshot.event_frontier.saturating_add(1);
297        summary
298    }
299
300    pub fn from_materialized(session: &MaterializedSession) -> Self {
301        let start = session
302            .transcript
303            .iter()
304            .filter(|i| mj_core::archive::is_context_boundary(&i.stable_id))
305            .map(|i| i.position)
306            .max()
307            .unwrap_or(0);
308        let cutoff = session
309            .transcript
310            .iter()
311            .filter(|i| start == 0 || i.position > start)
312            .filter(|i| matches!(i.body, TranscriptBody::Tool { .. }))
313            .rev()
314            .take(FULL_TOOL_CALLS)
315            .map(|i| i.position)
316            .min()
317            .unwrap_or(u64::MAX);
318        let mut summary = Self::default();
319        for item in session
320            .transcript
321            .iter()
322            .filter(|i| start == 0 || i.position > start)
323        {
324            let (role, text, tool) = match &item.body {
325                TranscriptBody::User { content } => (
326                    SummaryRole::User,
327                    user_text(materialized_content_text(content)),
328                    None,
329                ),
330                TranscriptBody::Agent { chunks, .. } => (
331                    SummaryRole::Assistant,
332                    materialized_chunks_text(chunks),
333                    None,
334                ),
335                TranscriptBody::Tool {
336                    call,
337                    terminal_outputs,
338                    presentation,
339                    ..
340                } => {
341                    let mut value =
342                        tool_value(call, presentation.as_deref(), item.position >= cutoff);
343                    attach_terminals(
344                        &mut value,
345                        terminal_outputs.iter().map(|t| {
346                            (
347                                &t.terminal_id,
348                                &t.output,
349                                t.exit_code,
350                                t.signal.as_deref(),
351                                t.truncated,
352                            )
353                        }),
354                    );
355                    (SummaryRole::Tool, tool_label(&value), Some(value))
356                }
357                TranscriptBody::Plan { plan } => (SummaryRole::Plan, plan.to_string(), None),
358                _ => continue,
359            };
360            summary.entries.push(SummaryEntry {
361                id: item.stable_id.clone(),
362                position: item.position,
363                created_at_ms: item.created_at_ms,
364                role,
365                text,
366                terminal_refs: tool
367                    .as_ref()
368                    .and_then(|v| v.get("call"))
369                    .map(crate::projection::tool_call_terminal_ids)
370                    .unwrap_or_default(),
371                tool,
372            });
373        }
374        summary.next_position = session.applied_event_ordinal.saturating_add(1);
375        summary
376    }
377
378    pub fn mark_earlier_history_omitted(&mut self) {
379        self.leading_omitted = true;
380    }
381
382    pub fn push_user(&mut self, text: &str) {
383        self.open_message = None;
384        self.push(
385            SummaryRole::User,
386            String::new(),
387            user_text(text.to_owned()),
388            None,
389        );
390    }
391
392    fn push(&mut self, role: SummaryRole, id: String, text: String, tool: Option<Value>) {
393        self.next_position = self.next_position.saturating_add(1);
394        self.entries.push(SummaryEntry {
395            id,
396            position: self.next_position,
397            created_at_ms: 0,
398            role,
399            text: excerpt(&text, LIVE_ITEM_BYTES),
400            terminal_refs: tool
401                .as_ref()
402                .and_then(|v| v.get("call"))
403                .map(crate::projection::tool_call_terminal_ids)
404                .unwrap_or_default(),
405            tool,
406        });
407        if self.entries.len() > LIVE_ITEMS {
408            let full_tools = self
409                .entries
410                .iter()
411                .enumerate()
412                .rev()
413                .filter(|(_, e)| e.role == SummaryRole::Tool)
414                .take(FULL_TOOL_CALLS)
415                .map(|(i, _)| i)
416                .collect::<Vec<_>>();
417            let latest_user = self
418                .entries
419                .iter()
420                .rposition(|e| e.role == SummaryRole::User);
421            let remove = (0..self.entries.len())
422                .find(|i| !full_tools.contains(i) && Some(*i) != latest_user)
423                .unwrap_or(0);
424            self.entries.remove(remove);
425            self.leading_omitted = true;
426        }
427    }
428
429    pub fn observe(&mut self, update: &SessionUpdate) {
430        match update {
431            SessionUpdate::AgentMessageChunk(chunk) => {
432                let id = chunk
433                    .message_id
434                    .as_ref()
435                    .map(ToString::to_string)
436                    .unwrap_or_default();
437                if let ContentBlock::Text(text) = &chunk.content {
438                    if self.open_message.as_ref() == Some(&id) {
439                        if let Some(entry) = self.entries.last_mut() {
440                            entry.text.push_str(&excerpt(&text.text, LIVE_ITEM_BYTES));
441                            if entry.text.len() > LIVE_ITEM_BYTES {
442                                entry.text = excerpt(&entry.text, LIVE_ITEM_BYTES);
443                            }
444                        }
445                    } else {
446                        self.push(
447                            SummaryRole::Assistant,
448                            id.clone(),
449                            excerpt(&text.text, LIVE_ITEM_BYTES),
450                            None,
451                        );
452                    }
453                    self.open_message = Some(id);
454                }
455            }
456            SessionUpdate::ToolCall(call) => {
457                self.open_message = None;
458                let id = call.tool_call_id.to_string();
459                let raw = serde_json::to_value(call).expect("serialize ACP call");
460                let full = self
461                    .entries
462                    .iter()
463                    .find(|e| e.id == id)
464                    .is_none_or(|e| e.tool.as_ref().is_some_and(|v| v.get("call").is_some()));
465                let value = tool_value(&raw, None, full);
466                if let Some(entry) = self
467                    .entries
468                    .iter_mut()
469                    .find(|e| e.role == SummaryRole::Tool && e.id == id)
470                {
471                    for id in value
472                        .get("call")
473                        .map(crate::projection::tool_call_terminal_ids)
474                        .unwrap_or_default()
475                    {
476                        if !entry.terminal_refs.contains(&id) {
477                            entry.terminal_refs.push(id);
478                        }
479                    }
480                    entry.text = tool_label(&value);
481                    entry.tool = Some(value);
482                } else {
483                    self.push(SummaryRole::Tool, id, tool_label(&value), Some(value));
484                }
485                self.demote_tools();
486            }
487            SessionUpdate::ToolCallUpdate(update) => {
488                self.open_message = None;
489                if let Some(entry) = self.entries.iter_mut().find(|e| {
490                    e.role == SummaryRole::Tool && e.id == update.tool_call_id.to_string()
491                }) {
492                    let old = entry.tool.as_ref().expect("tool entry");
493                    let full = old.get("call").is_some();
494                    let mut call: ToolCall = old
495                        .get("call")
496                        .and_then(|v| serde_json::from_value(v.clone()).ok())
497                        .unwrap_or_else(|| {
498                            ToolCall::new(
499                                update.tool_call_id.clone(),
500                                old["name"].as_str().unwrap_or("tool"),
501                            )
502                        });
503                    let previous_name = old["name"].clone();
504                    let previous_status = old["status"].clone();
505                    call.update(update.fields.clone());
506                    let raw = serde_json::to_value(call).expect("serialize ACP call");
507                    let mut value = tool_value(&raw, None, full);
508                    if !full && update.fields.raw_input.is_none() {
509                        value["name"] = previous_name;
510                    }
511                    if update.fields.kind.is_none() {
512                        value["kind"] = old["kind"].clone();
513                    }
514                    if update.fields.status.is_none() {
515                        value["status"] = previous_status;
516                    }
517                    for key in ["exit_code", "signal", "terminals"] {
518                        if value.get(key).is_none()
519                            && let Some(old_value) = old.get(key)
520                        {
521                            value[key] = old_value.clone();
522                        }
523                    }
524                    for id in value
525                        .get("call")
526                        .map(crate::projection::tool_call_terminal_ids)
527                        .unwrap_or_default()
528                    {
529                        if !entry.terminal_refs.contains(&id) {
530                            entry.terminal_refs.push(id);
531                        }
532                    }
533                    entry.text = tool_label(&value);
534                    entry.tool = Some(value);
535                }
536            }
537            SessionUpdate::AgentThoughtChunk(_) => self.open_message = None,
538            SessionUpdate::Plan(plan) => {
539                self.open_message = None;
540                self.push(
541                    SummaryRole::Plan,
542                    String::new(),
543                    serde_json::to_string(plan).expect("serialize plan"),
544                    None,
545                );
546            }
547            _ => {}
548        }
549        // Bound retained live payloads, including strings nested in raw ACP records.
550        if matches!(
551            update,
552            SessionUpdate::ToolCall(_) | SessionUpdate::ToolCallUpdate(_)
553        ) {
554            for entry in &mut self.entries {
555                if let Some(value) = &mut entry.tool {
556                    bound_value(value, LIVE_ITEM_BYTES);
557                }
558            }
559        }
560    }
561
562    pub fn observe_terminal(&mut self, terminal: &mj_core::transcript::TerminalOutputRecord) {
563        for entry in &mut self.entries {
564            let Some(value) = &mut entry.tool else {
565                continue;
566            };
567            let refers = entry.terminal_refs.contains(&terminal.terminal_id);
568            if refers {
569                attach_terminals(
570                    value,
571                    std::iter::once((
572                        &terminal.terminal_id,
573                        &terminal.output,
574                        terminal.exit_code,
575                        terminal.signal.as_deref(),
576                        terminal.truncated,
577                    )),
578                );
579                bound_value(value, LIVE_ITEM_BYTES);
580                entry.text = tool_label(value);
581            }
582        }
583    }
584
585    fn demote_tools(&mut self) {
586        for entry in self
587            .entries
588            .iter_mut()
589            .rev()
590            .filter(|e| e.role == SummaryRole::Tool)
591            .skip(FULL_TOOL_CALLS)
592        {
593            if let Some(value) = entry.tool.as_mut().and_then(Value::as_object_mut) {
594                value.remove("call");
595                value.remove("terminals");
596            }
597        }
598    }
599
600    /// Jev's current-request conversation, without historical tool-call records.
601    /// Keep the available suffix and its coverage marker if replay lacks the user boundary.
602    pub fn latest_user_messages(&self) -> Self {
603        let user = self
604            .entries
605            .iter()
606            .rposition(|entry| entry.role == SummaryRole::User);
607        Self {
608            entries: self.entries[user.unwrap_or(0)..]
609                .iter()
610                .filter(|entry| entry.role != SummaryRole::Tool)
611                .cloned()
612                .collect(),
613            leading_omitted: self.leading_omitted && user.is_none(),
614            ..Default::default()
615        }
616    }
617
618    /// Exact retained context alongside a model-produced summary, newest-first selection.
619    pub fn retained(&self) -> Self {
620        let last_assistant = self
621            .entries
622            .iter()
623            .rposition(|e| e.role == SummaryRole::Assistant);
624        Self {
625            entries: self
626                .entries
627                .iter()
628                .enumerate()
629                .filter(|(i, e)| {
630                    e.role == SummaryRole::User
631                        || Some(*i) == last_assistant
632                        || e.tool.as_ref().is_some_and(|v| v.get("call").is_some())
633                })
634                .map(|(_, e)| e.clone())
635                .collect(),
636            ..Default::default()
637        }
638    }
639
640    /// Preserve recent context first, then spend spare space on newest history.
641    /// Only after optional history is removed do oversized retained bodies shrink.
642    pub fn render(&self, limit: usize) -> String {
643        self.render_with_ids(limit).0
644    }
645
646    /// Return the retained entry identities as well as text, so offline evidence
647    /// selectors never ask about entries omitted by the rendering budget.
648    pub fn render_with_ids(&self, limit: usize) -> (String, Vec<String>) {
649        let history_marker = if self.leading_omitted {
650            "[Earlier transcript entries omitted]\n"
651        } else {
652            ""
653        };
654        if limit < history_marker.len() {
655            return (excerpt(history_marker, limit), Vec::new());
656        }
657        let all = history_marker.to_owned()
658            + &self
659                .entries
660                .iter()
661                .map(|e| e.render(usize::MAX))
662                .collect::<String>();
663        if all.len() <= limit {
664            return (
665                all,
666                self.entries.iter().map(|entry| entry.id.clone()).collect(),
667            );
668        }
669
670        let mut selected = std::collections::BTreeSet::new();
671        for role in [SummaryRole::User, SummaryRole::Assistant] {
672            if let Some(index) = self.entries.iter().rposition(|e| e.role == role) {
673                selected.insert(index);
674            }
675        }
676        selected.extend(
677            self.entries
678                .iter()
679                .enumerate()
680                .rev()
681                .filter(|(_, e)| e.role == SummaryRole::Tool)
682                .take(FULL_TOOL_CALLS)
683                .map(|(i, _)| i),
684        );
685        let mut used = selected
686            .iter()
687            .map(|i| self.entries[*i].render(usize::MAX).len())
688            .sum::<usize>();
689        // Reserve enough room for both coverage markers before adding optional items.
690        let available = limit.saturating_sub(history_marker.len() + 64);
691        if used < available {
692            // Codex-style retention: recent user messages take precedence over old activity.
693            for users in [true, false] {
694                for (index, entry) in self.entries.iter().enumerate().rev() {
695                    if selected.contains(&index) || (entry.role == SummaryRole::User) != users {
696                        continue;
697                    }
698                    let size = entry.render(usize::MAX).len();
699                    if used.saturating_add(size) <= available {
700                        selected.insert(index);
701                        used += size;
702                    }
703                }
704            }
705        }
706        let omitted = self.entries.len() - selected.len();
707        let prefix = history_marker.to_owned()
708            + &if omitted > 0 {
709                format!("[{omitted} transcript entries omitted]\n")
710            } else {
711                String::new()
712            };
713        let render = |cap| {
714            prefix.clone()
715                + &selected
716                    .iter()
717                    .map(|i| self.entries[*i].render(cap))
718                    .collect::<String>()
719        };
720        let ids = selected
721            .iter()
722            .map(|i| self.entries[*i].id.clone())
723            .collect();
724        let full = render(usize::MAX);
725        if full.len() <= limit {
726            return (full, ids);
727        }
728        let minimum = render(0);
729        if minimum.len() > limit {
730            return (excerpt(&minimum, limit), Vec::new());
731        }
732        // A common cap leaves short messages intact and shares remaining space among long bodies.
733        let (mut low, mut high) = (0, limit);
734        while low < high {
735            let middle = low + (high - low).div_ceil(2);
736            if render(middle).len() <= limit {
737                low = middle;
738            } else {
739                high = middle - 1;
740            }
741        }
742        (render(low), ids)
743    }
744}
745
746fn attach_terminals<'a>(
747    value: &mut Value,
748    terminals: impl Iterator<Item = (&'a String, &'a String, Option<u32>, Option<&'a str>, bool)>,
749) {
750    for (id, output, code, signal, truncated) in terminals {
751        if let Some(code) = code {
752            value["exit_code"] = json!(code);
753        }
754        if let Some(signal) = signal {
755            value["signal"] = json!(signal);
756        }
757        if value.get("call").is_some() {
758            if !value["terminals"].is_object() {
759                value["terminals"] = json!({});
760            }
761            value["terminals"][id] =
762                json!({"output":output,"exit_code":code,"signal":signal,"truncated":truncated});
763        }
764    }
765}
766
767fn bound_value(value: &mut Value, limit: usize) {
768    match value {
769        Value::String(text) if text.len() > limit => *text = excerpt(text, limit),
770        Value::Array(values) => {
771            for value in values {
772                bound_value(value, limit);
773            }
774        }
775        Value::Object(values) => {
776            for value in values.values_mut() {
777                bound_value(value, limit);
778            }
779        }
780        _ => {}
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use agent_client_protocol::schema::v1::{
788        ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
789    };
790    use mj_core::state::TranscriptItem;
791    use std::sync::Arc;
792
793    fn call(n: usize) -> ToolCall {
794        ToolCall::new(format!("call-{n}"), "Run command")
795            .kind(ToolKind::Execute)
796            .status(ToolCallStatus::Completed)
797            .raw_input(json!({"command": format!("cargo test --marker=ARGUMENT_{n}")}))
798            .raw_output(json!({"exit_code": n, "formatted_output": format!("OUTPUT_{n}")}))
799    }
800
801    #[test]
802    fn legacy_persisted_index_title_uses_its_stable_fallback_id() {
803        let legacy_title =
804            include_str!("../tests/fixtures/legacy-indexed-tool-call.txt").trim_end();
805
806        let (call, terminals) = indexed_tool_call(legacy_title, "legacy-tool-17");
807
808        assert_eq!(
809            call,
810            json!({
811                "toolCallId": "legacy-tool-17",
812                "title": "Terminal",
813                "status": "completed"
814            })
815        );
816        assert!(terminals.is_empty());
817    }
818
819    #[test]
820    fn ninth_call_demotes_first_and_late_updates_do_not_promote_it() {
821        let mut summary = TranscriptSummary::default();
822        summary.push_user("test the change");
823        for n in 0..9 {
824            summary.observe(&SessionUpdate::ToolCall(call(n)));
825        }
826        summary.observe(&SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
827            "call-0",
828            ToolCallUpdateFields::new()
829                .status(ToolCallStatus::Failed)
830                .raw_output(json!({"exit_code": 42,"output":"LATE_PAYLOAD"})),
831        )));
832        let output = summary.render(DEFAULT_SUMMARY_BYTES);
833        assert!(!output.contains("ARGUMENT_0"));
834        assert!(!output.contains("OUTPUT_0"));
835        assert!(!output.contains("LATE_PAYLOAD"));
836        assert!(output.contains("cargo test [failed] exit=42"));
837        for n in 1..9 {
838            assert!(output.contains(&format!("ARGUMENT_{n}")));
839            assert!(output.contains(&format!("OUTPUT_{n}")));
840        }
841        assert_eq!(
842            summary
843                .entries
844                .iter()
845                .filter(|e| e.tool.as_ref().is_some_and(|v| v.get("call").is_some()))
846                .count(),
847            FULL_TOOL_CALLS
848        );
849    }
850
851    #[test]
852    fn huge_unicode_bodies_are_marked_and_preserve_recent_outcomes() {
853        let mut summary = TranscriptSummary::default();
854        summary.push_user("latest user requirement");
855        for n in 0..8 {
856            summary.observe(&SessionUpdate::ToolCall(
857                call(n).raw_output(json!({"exit_code":n,"output":"🦀\"\\\n".repeat(70_000)})),
858            ));
859        }
860        let output = summary.render(16 * 1024);
861        assert!(output.len() <= 16 * 1024);
862        assert!(output.contains("bytes omitted"));
863        assert!(output.contains("latest user requirement"));
864        for n in 0..8 {
865            assert!(output.contains(&format!("exit={n}")));
866        }
867        for limit in 0..100 {
868            let excerpt = excerpt("🦀".repeat(100).as_str(), limit);
869            assert!(excerpt.len() <= limit);
870        }
871    }
872
873    #[test]
874    fn context_boundary_discards_previous_conversation() {
875        let mut session = MaterializedSession::empty("summary-test");
876        for (position, id, body) in [
877            (
878                1,
879                "user:old",
880                TranscriptBody::User {
881                    content: vec![json!({"type":"text","text":"OLD_CONTEXT"})],
882                },
883            ),
884            (
885                2,
886                "context-clear:test",
887                TranscriptBody::System {
888                    text: "cleared".into(),
889                },
890            ),
891            (
892                3,
893                "user:new",
894                TranscriptBody::User {
895                    content: vec![json!({"type":"text","text":"NEW_CONTEXT"})],
896                },
897            ),
898        ] {
899            let id = if position == 2 {
900                format!("{}test", mj_core::archive::CONTEXT_BOUNDARY_PREFIX)
901            } else {
902                id.into()
903            };
904            session.transcript.push(Arc::new(TranscriptItem {
905                stable_id: id,
906                position,
907                latest_content_event_ordinal: None,
908                created_at_ms: 0,
909                last_changed_at_ms: 0,
910                body,
911            }));
912        }
913        let output = TranscriptSummary::from_materialized(&session).render(4096);
914        assert!(!output.contains("OLD_CONTEXT"));
915        assert!(output.contains("NEW_CONTEXT"));
916    }
917
918    #[test]
919    fn terminal_output_and_exit_are_attached_and_partial_updates_keep_name() {
920        let mut summary = TranscriptSummary::default();
921        let call: ToolCall = serde_json::from_value(json!({"toolCallId":"terminal","title":"Run command","kind":"execute","status":"in_progress","rawInput":{"command":"cargo test --secret argument"},"content":[{"type":"terminal","terminalId":"term-1"}]})).unwrap();
922        summary.observe(&SessionUpdate::ToolCall(call));
923        summary.observe_terminal(&mj_core::transcript::TerminalOutputRecord {
924            terminal_id: "term-1".into(),
925            output: "terminal result".into(),
926            truncated: false,
927            exit_code: Some(17),
928            signal: None,
929        });
930        summary.observe(&SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
931            "terminal",
932            ToolCallUpdateFields::new()
933                .title("Done")
934                .status(ToolCallStatus::Failed),
935        )));
936        let output = summary.render(4096);
937        assert!(output.contains("cargo test [failed] exit=17"));
938        assert!(output.contains("terminal result"));
939    }
940    #[test]
941    fn demoted_terminal_keeps_late_outcome_without_its_body() {
942        let mut summary = TranscriptSummary::default();
943        let terminal: ToolCall = serde_json::from_value(json!({"toolCallId":"terminal","title":"cargo test","kind":"execute","status":"in_progress","content":[{"type":"terminal","terminalId":"term"}]})).unwrap();
944        summary.observe(&SessionUpdate::ToolCall(terminal));
945        for n in 0..8 {
946            summary.observe(&SessionUpdate::ToolCall(call(n)));
947        }
948        summary.observe_terminal(&mj_core::transcript::TerminalOutputRecord {
949            terminal_id: "term".into(),
950            output: "OLD_TERMINAL_PAYLOAD".into(),
951            exit_code: Some(23),
952            signal: None,
953            truncated: false,
954        });
955        let output = summary.render(DEFAULT_SUMMARY_BYTES);
956        assert!(output.contains("exit=23"));
957        assert!(!output.contains("OLD_TERMINAL_PAYLOAD"));
958    }
959
960    #[test]
961    fn malformed_older_calls_do_not_leak_their_titles_or_payloads() {
962        let invalid =
963            json!({"title":"SECRET_TITLE", "rawInput":"SECRET_INPUT", "status":"completed"});
964        let compact = tool_value(&invalid, None, false).to_string();
965        assert!(compact.contains("invalid tool call"));
966        assert!(!compact.contains("SECRET"));
967    }
968}