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 = 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 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 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
110pub 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
162pub 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 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 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 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 pub fn render(&self, limit: usize) -> String {
643 self.render_with_ids(limit).0
644 }
645
646 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 let available = limit.saturating_sub(history_marker.len() + 64);
691 if used < available {
692 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 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}