1use 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
12pub const HANDOFF_PREAMBLE: &str =
16 "You are continuing a coding session previously run by another ACP harness.";
17pub const ARCHIVE_HANDOFF_PREAMBLE: &str = "Archived session restored from SessionWiki.";
21pub const LEGACY_HANDOFF_PREAMBLE: &str =
24 "Continue this coding session from the portable transcript below.";
25pub const HANDOFF_PLACEHOLDER: &str =
29 "[cross-harness resume handoff: continuing work from a prior harness]";
30fn 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;
48pub 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 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 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
112pub 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
164pub 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 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 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 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 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 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 pub fn render(&self, limit: usize) -> String {
673 self.render_with_ids(limit).0
674 }
675
676 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 let available = limit.saturating_sub(history_marker.len() + 64);
725 if used < available {
726 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 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}