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