返回 CodeWhale
session_peek.rs
根目录 / crates / tui / src / session_peek.rs
1 //! Bounded, redacted, read-only transcript peek for the dashboard (#4397).
2 //!
3 //! The dashboard needs to show *what a saved session was about* without
4 //! becoming a second transcript viewer. Three constraints shape this module,
5 //! and all three are enforced here rather than in the client:
6 //!
7 //! * **Bounded on the wire.** The peek carries at most
8 //! [`MAX_PEEK_ENTRIES`] entries of at most [`MAX_ENTRY_CHARS`] characters.
9 //! Doing this client-side would mean shipping a multi-megabyte transcript to
10 //! a browser in order to throw most of it away.
11 //! * **Redacted.** A saved transcript can contain an API key a user pasted, a
12 //! token echoed by a tool, an `Authorization` header in a curl command. The
13 //! dashboard is reachable over a LAN; a peek pane is not the place to
14 //! re-emit those.
15 //! * **Read-only and non-live.** A peek is a recording. It carries no turn
16 //! status, no "running" flag, nothing that could be mistaken for live state.
17 //! Live state comes from a resumed thread and its SSE stream, never from
18 //! here — see `runtime_web/app.mjs`'s reply-target rules.
19 //!
20 //! Tool payloads are summarised to a kind and a size, never inlined: a tool
21 //! result is the most likely place for both bulk and secrets.
22
23 use serde::Serialize;
24
25 use crate::session_manager::SavedSession;
26 use codewhale_models::ContentBlock;
27
28 /// Most entries a peek carries. The dashboard shows a tail, so this is "the
29 /// last N exchanges", which is what a peek is for.
30 pub const MAX_PEEK_ENTRIES: usize = 12;
31
32 /// Longest text any single entry carries.
33 pub const MAX_ENTRY_CHARS: usize = 400;
34
35 /// What produced an entry. Deliberately coarse — the peek is not a
36 /// reconstruction of the turn structure.
37 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38 #[serde(rename_all = "snake_case")]
39 pub enum PeekEntryKind {
40 User,
41 Assistant,
42 Reasoning,
43 /// A tool call or result, summarised. Never the payload itself.
44 Tool,
45 }
46
47 /// One bounded line of recorded conversation.
48 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49 pub struct PeekEntry {
50 pub kind: PeekEntryKind,
51 /// Already bounded and redacted. Safe to render as text — and only as
52 /// text; the client inserts it with `textContent`, never `innerHTML`.
53 pub text: String,
54 /// True when [`Self::text`] was shortened.
55 pub truncated: bool,
56 /// True when at least one redaction was applied.
57 pub redacted: bool,
58 }
59
60 /// A read-only view of a saved session.
61 ///
62 /// Note what is absent: no turn status, no `active`, no `running`. A saved
63 /// session has none of those, and inventing them is the fabricated-live-state
64 /// failure this whole slice exists to avoid.
65 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66 pub struct SessionPeek {
67 pub session_id: String,
68 pub title: String,
69 pub workspace: std::path::PathBuf,
70 pub model: String,
71 pub mode: String,
72 pub archived: bool,
73 /// Messages of conversation, runtime control traffic excluded. The peek
74 /// never shows that traffic, so counting it here would print a total the
75 /// pane cannot account for.
76 pub message_count: usize,
77 pub updated_at: chrono::DateTime<chrono::Utc>,
78 /// Entries actually carried, oldest-first within the tail.
79 pub entries: Vec<PeekEntry>,
80 /// How many messages were dropped from the front to fit the bound. The
81 /// client shows this rather than implying it has the whole conversation.
82 pub omitted_before: usize,
83 /// Always true: a peek is a recording of a saved session, never a live
84 /// thread. Serialised so a client cannot mistake one payload for the
85 /// other even by accident.
86 pub live: bool,
87 }
88
89 /// Build a bounded, redacted peek from a loaded session.
90 #[must_use]
91 pub fn build_peek(session: &SavedSession, max_entries: usize) -> SessionPeek {
92 let max_entries = max_entries.clamp(1, MAX_PEEK_ENTRIES);
93 // Runtime control traffic is persisted with `role = "user"` because strict
94 // chat templates reject anything else mid-conversation — see
95 // `runtime_handoff`, which owns both the envelope and its recognition.
96 // Rendering that transport role would attribute the runtime's own
97 // bookkeeping to the person, and in a session with busy sub-agents it is
98 // most of what the pane would show. Drop it before the tail is taken:
99 // filtering afterwards spends the entry budget on rows nobody sees.
100 let conversation: Vec<_> = session
101 .messages
102 .iter()
103 .filter(|message| !crate::runtime_handoff::is_internal_runtime_handoff(message))
104 .collect();
105 let total = conversation.len();
106 let start = total.saturating_sub(max_entries);
107
108 let entries: Vec<PeekEntry> = conversation[start..]
109 .iter()
110 .map(|message| {
111 let kind = match message.role.as_str() {
112 "user" => PeekEntryKind::User,
113 _ => PeekEntryKind::Assistant,
114 };
115 entry_for_blocks(kind, &message.content)
116 })
117 .collect();
118
119 SessionPeek {
120 session_id: session.metadata.id.clone(),
121 title: session.metadata.title.clone(),
122 workspace: session.metadata.workspace.clone(),
123 model: session.metadata.model.clone(),
124 mode: session
125 .metadata
126 .mode
127 .clone()
128 .unwrap_or_else(|| "agent".to_string()),
129 archived: session.metadata.archived,
130 message_count: total,
131 updated_at: session.metadata.updated_at,
132 entries,
133 omitted_before: start,
134 live: false,
135 }
136 }
137
138 fn entry_for_blocks(default_kind: PeekEntryKind, blocks: &[ContentBlock]) -> PeekEntry {
139 let mut kind = default_kind;
140 let mut parts: Vec<String> = Vec::new();
141
142 for block in blocks {
143 match block {
144 ContentBlock::Text { text, .. } => parts.push(text.trim().to_string()),
145 ContentBlock::Thinking { thinking, .. } => {
146 kind = PeekEntryKind::Reasoning;
147 parts.push(thinking.trim().to_string());
148 }
149 // Tool traffic is summarised, never inlined: it is the most
150 // likely carrier of both bulk output and credentials.
151 ContentBlock::ToolUse { name, .. } | ContentBlock::ServerToolUse { name, .. } => {
152 kind = PeekEntryKind::Tool;
153 parts.push(format!("[tool call: {name}]"));
154 }
155 ContentBlock::ToolResult { content, .. } => {
156 kind = PeekEntryKind::Tool;
157 parts.push(format!("[tool result: {} chars]", content.chars().count()));
158 }
159 // Structured tool results are JSON. Report their serialized size
160 // rather than their shape: the size is the honest number, and any
161 // field of the payload could be a credential.
162 ContentBlock::ToolSearchToolResult { content, .. }
163 | ContentBlock::CodeExecutionToolResult { content, .. } => {
164 kind = PeekEntryKind::Tool;
165 parts.push(format!(
166 "[tool result: {} chars]",
167 content.to_string().len()
168 ));
169 }
170 ContentBlock::ImageUrl { .. } => {
171 kind = PeekEntryKind::Tool;
172 parts.push("[image]".to_string());
173 }
174 }
175 }
176
177 let joined = parts
178 .into_iter()
179 .filter(|part| !part.is_empty())
180 .collect::<Vec<_>>()
181 .join(" ");
182 let (text, redacted) = redact(&joined);
183 let (text, truncated) = bound(&text, MAX_ENTRY_CHARS);
184
185 PeekEntry {
186 kind,
187 text,
188 truncated,
189 redacted,
190 }
191 }
192
193 fn bound(text: &str, max_chars: usize) -> (String, bool) {
194 if text.chars().count() <= max_chars {
195 return (text.to_string(), false);
196 }
197 let kept: String = text.chars().take(max_chars.saturating_sub(1)).collect();
198 (format!("{kept}…"), true)
199 }
200
201 /// Placeholder substituted for anything that looks like a credential.
202 pub const REDACTED_PLACEHOLDER: &str = "[redacted]";
203
204 /// Mask credential-shaped substrings.
205 ///
206 /// The shared export sanitizer ([`codewhale_secrets::sanitize::sanitize_text`],
207 /// also used by `/export`) runs first: it covers `Bearer <token>`, JWTs,
208 /// URL credentials, PEM blocks, and quoted JSON/TOML keyed secrets. A
209 /// token-level pass then masks the extra bare prefixes (AWS, Google, Hugging
210 /// Face) and long opaque runs a transcript tends to carry. Over-redacting a
211 /// peek line is cheap; leaking a key over a LAN is not.
212 #[must_use]
213 pub fn redact(text: &str) -> (String, bool) {
214 let sanitized = codewhale_secrets::sanitize::sanitize_text(text);
215 // The sanitizer also strips control bytes and normalises URLs, so compare
216 // placeholder counts rather than bytes to decide whether it redacted.
217 let mut redacted = redaction_marks(&sanitized) > redaction_marks(text);
218 let mut out = String::with_capacity(sanitized.len());
219
220 for token in sanitized.split_inclusive(char::is_whitespace) {
221 let trimmed = token.trim_end();
222 let trailing = &token[trimmed.len()..];
223 // Keep closing quotes and punctuation outside the mask so a masked
224 // token inside `"…"` or `(…)` does not swallow its delimiter.
225 let core = trimmed.trim_end_matches(['"', '\'', '`', ',', ';', ')', ']', '}']);
226 let closing = &trimmed[core.len()..];
227 if looks_like_secret(core) {
228 out.push_str(REDACTED_PLACEHOLDER);
229 out.push_str(closing);
230 out.push_str(trailing);
231 redacted = true;
232 } else if let Some(masked) = mask_assignment(core) {
233 out.push_str(&masked);
234 out.push_str(closing);
235 out.push_str(trailing);
236 redacted = true;
237 } else {
238 out.push_str(token);
239 }
240 }
241
242 (out, redacted)
243 }
244
245 fn redaction_marks(text: &str) -> usize {
246 text.matches("[redacted").count() + text.matches("***").count()
247 }
248
249 /// Known credential prefixes plus long opaque runs.
250 fn looks_like_secret(token: &str) -> bool {
251 const PREFIXES: &[&str] = &[
252 "sk-",
253 "sk_",
254 "pk_",
255 "ghp_",
256 "gho_",
257 "ghu_",
258 "ghs_",
259 "github_pat_",
260 "xoxb-",
261 "xoxp-",
262 "AKIA",
263 "ASIA",
264 "AIza",
265 "hf_",
266 "Bearer",
267 ];
268 if PREFIXES
269 .iter()
270 .any(|prefix| token.len() > prefix.len() && token.starts_with(prefix))
271 {
272 return true;
273 }
274 // A long unbroken run of base64/hex-ish characters is almost never prose.
275 token.len() >= 32
276 && token
277 .chars()
278 .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' || c == '_')
279 && token.chars().any(|c| c.is_ascii_digit())
280 && token.chars().any(|c| c.is_ascii_alphabetic())
281 }
282
283 /// `key=value`, `token: value`, `--password value` style assignments.
284 fn mask_assignment(token: &str) -> Option<String> {
285 const KEYS: &[&str] = &[
286 "api_key",
287 "apikey",
288 "api-key",
289 "token",
290 "secret",
291 "password",
292 "passwd",
293 "authorization",
294 "auth",
295 "credential",
296 ];
297 let (name, sep_index) = token
298 .find('=')
299 .map(|i| (&token[..i], i))
300 .or_else(|| token.find(':').map(|i| (&token[..i], i)))?;
301 let normalized = name.trim_start_matches('-').to_ascii_lowercase();
302 if !KEYS.contains(&normalized.as_str()) {
303 return None;
304 }
305 if token[sep_index + 1..].trim().is_empty() {
306 return None;
307 }
308 Some(format!(
309 "{}{}{REDACTED_PLACEHOLDER}",
310 name,
311 &token[sep_index..=sep_index]
312 ))
313 }
314
315 #[cfg(test)]
316 mod tests {
317 use super::*;
318 use crate::session_manager::create_saved_session_with_id_and_mode;
319 use codewhale_models::Message;
320 use codewhale_models::Role;
321
322 fn text_block(text: &str) -> ContentBlock {
323 ContentBlock::Text {
324 text: text.to_string(),
325 cache_control: None,
326 }
327 }
328
329 fn session_with(messages: Vec<Message>) -> SavedSession {
330 create_saved_session_with_id_and_mode(
331 "peek-session".to_string(),
332 &messages,
333 "deepseek-chat",
334 std::path::Path::new("/repo"),
335 10,
336 None,
337 Some("agent"),
338 )
339 }
340
341 fn user(text: &str) -> Message {
342 Message {
343 role: Role::User,
344 content: vec![text_block(text)],
345 }
346 }
347
348 #[test]
349 fn peek_is_bounded_in_entries_and_reports_what_it_dropped() {
350 let messages: Vec<Message> = (0..40).map(|i| user(&format!("message {i}"))).collect();
351 let peek = build_peek(&session_with(messages), MAX_PEEK_ENTRIES);
352
353 assert_eq!(peek.entries.len(), MAX_PEEK_ENTRIES);
354 assert_eq!(peek.message_count, 40);
355 assert_eq!(peek.omitted_before, 40 - MAX_PEEK_ENTRIES);
356 assert!(
357 peek.entries.last().expect("tail").text.contains("39"),
358 "the peek must be the tail, not the head"
359 );
360 }
361
362 #[test]
363 fn a_request_for_more_than_the_cap_still_gets_the_cap() {
364 let messages: Vec<Message> = (0..100).map(|i| user(&format!("m{i}"))).collect();
365 let peek = build_peek(&session_with(messages), usize::MAX);
366 assert_eq!(peek.entries.len(), MAX_PEEK_ENTRIES);
367 }
368
369 #[test]
370 fn long_entries_are_truncated_and_flagged() {
371 let peek = build_peek(&session_with(vec![user(&"x".repeat(5_000))]), 4);
372 let entry = &peek.entries[0];
373 assert!(entry.truncated);
374 assert!(entry.text.chars().count() <= MAX_ENTRY_CHARS);
375 }
376
377 #[test]
378 fn credentials_are_redacted_out_of_peek_text() {
379 for secret in [
380 "sk-abcdefghijklmnopqrstuvwxyz123456",
381 "ghp_abcdefghijklmnopqrstuvwxyz1234",
382 "AKIAIOSFODNN7EXAMPLE",
383 ] {
384 let peek = build_peek(&session_with(vec![user(&format!("here: {secret}"))]), 4);
385 let entry = &peek.entries[0];
386 assert!(entry.redacted, "{secret} should have been redacted");
387 assert!(
388 !entry.text.contains(secret),
389 "peek leaked {secret}: {}",
390 entry.text
391 );
392 assert!(entry.text.contains(REDACTED_PLACEHOLDER));
393 }
394 }
395
396 #[test]
397 fn assignment_style_secrets_are_masked_but_keep_their_key() {
398 let (masked, redacted) = redact("api_key=hunter2 and password:swordfish");
399 assert!(redacted);
400 assert!(masked.contains("api_key="));
401 assert!(!masked.contains("hunter2"));
402 assert!(!masked.contains("swordfish"));
403 }
404
405 #[test]
406 fn bearer_jwt_and_quoted_json_secrets_are_redacted() {
407 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
408 let bearer = format!("Authorization: Bearer {jwt}");
409 let json_key = r#"{"api_key": "sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789"}"#;
410 for (input, secret) in [
411 (bearer.as_str(), jwt),
412 (
413 json_key,
414 "sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789",
415 ),
416 (
417 "run: curl https://user:hunter2pass@api.example/v1 now",
418 "hunter2pass",
419 ),
420 ] {
421 let (out, redacted) = redact(input);
422 assert!(redacted, "{input} should have been redacted: {out}");
423 assert!(!out.contains(secret), "peek leaked {secret}: {out}");
424 }
425 }
426
427 #[test]
428 fn a_masked_token_keeps_its_closing_quote() {
429 let (out, redacted) =
430 redact(r#"curl -H "X-Key: ghp_abcdefghijklmnopqrstuvwxyz1234" https://api.example"#);
431 assert!(redacted);
432 assert!(!out.contains("ghp_abcdefghijklmnopqrstuvwxyz1234"), "{out}");
433 assert!(out.contains(r#"[redacted]" https://api.example"#), "{out}");
434 }
435
436 #[test]
437 fn ordinary_prose_is_not_redacted() {
438 let (out, redacted) = redact("Please refactor the lane registry and update the docs.");
439 assert!(!redacted);
440 assert_eq!(
441 out,
442 "Please refactor the lane registry and update the docs."
443 );
444 }
445
446 #[test]
447 fn tool_payloads_are_summarised_never_inlined() {
448 let message = Message {
449 role: Role::Assistant,
450 content: vec![
451 ContentBlock::ToolUse {
452 execution_id: None,
453 id: "call-1".to_string(),
454 name: "read_file".to_string(),
455 input: serde_json::json!({ "path": "/etc/shadow" }),
456 caller: None,
457 thought_signature: None,
458 },
459 ContentBlock::ToolResult {
460 execution_id: None,
461 tool_use_id: "call-1".to_string(),
462 content: "root:$6$verysecrethash".to_string(),
463 is_error: None,
464 content_blocks: None,
465 },
466 ],
467 };
468 let peek = build_peek(&session_with(vec![message]), 4);
469 let entry = &peek.entries[0];
470
471 assert_eq!(entry.kind, PeekEntryKind::Tool);
472 assert!(entry.text.contains("[tool call: read_file]"));
473 assert!(entry.text.contains("[tool result:"));
474 assert!(
475 !entry.text.contains("verysecrethash"),
476 "tool output must never be inlined into a peek: {}",
477 entry.text
478 );
479 assert!(
480 !entry.text.contains("/etc/shadow"),
481 "tool input must not be inlined either: {}",
482 entry.text
483 );
484 }
485
486 #[test]
487 fn a_peek_never_claims_to_be_live() {
488 let peek = build_peek(&session_with(vec![user("hello")]), 4);
489 assert!(!peek.live, "a saved session is a recording, never live");
490 let json = serde_json::to_value(&peek).expect("serialize");
491 for forbidden in ["status", "running", "active", "turn"] {
492 assert!(
493 json.get(forbidden).is_none(),
494 "peek payload must not carry a `{forbidden}` field a client could read as live state"
495 );
496 }
497 }
498
499 #[test]
500 fn archive_state_rides_along_so_the_dashboard_need_not_guess() {
501 let mut session = session_with(vec![user("hello")]);
502 session.metadata.archived = true;
503 assert!(build_peek(&session, 4).archived);
504 }
505
506 /// Every runtime handoff shape that can reach a saved session, including
507 /// the restore checkpoints a post-resume save persists.
508 fn runtime_handoffs() -> Vec<(&'static str, Message)> {
509 let waiting = crate::runtime_handoff::waiting_for_subagents_runtime_message(2);
510 let restored =
511 crate::runtime_handoff::project_owned_messages_for_restore(vec![waiting.clone()]);
512 vec![
513 ("waiting_for_subagents", waiting),
514 (
515 "background_shell_completion",
516 crate::runtime_handoff::shell_completion_runtime_message(&[]),
517 ),
518 (
519 "restored_checkpoint",
520 restored.into_iter().next().expect("projected"),
521 ),
522 ]
523 }
524
525 #[test]
526 fn internal_runtime_events_are_absent_from_a_peek() {
527 for (kind, handoff) in runtime_handoffs() {
528 let peek = build_peek(
529 &session_with(vec![user("ship the release"), handoff]),
530 MAX_PEEK_ENTRIES,
531 );
532
533 assert_eq!(
534 peek.entries.len(),
535 1,
536 "{kind} was rendered as conversation: {:?}",
537 peek.entries
538 );
539 assert_eq!(peek.entries[0].text, "ship the release");
540 for entry in &peek.entries {
541 assert!(
542 !entry.text.contains("<codewhale:runtime_event"),
543 "{kind} leaked its envelope into a peek entry: {}",
544 entry.text
545 );
546 assert!(
547 !entry.text.contains("[Codewhale restored"),
548 "{kind} leaked a restore checkpoint into a peek entry: {}",
549 entry.text
550 );
551 }
552 }
553 }
554
555 #[test]
556 fn real_user_messages_survive_the_runtime_filter() {
557 let mut messages = vec![user("first")];
558 for (_, handoff) in runtime_handoffs() {
559 messages.push(handoff);
560 }
561 messages.push(user("second"));
562
563 let peek = build_peek(&session_with(messages), MAX_PEEK_ENTRIES);
564
565 let texts: Vec<&str> = peek.entries.iter().map(|e| e.text.as_str()).collect();
566 assert_eq!(texts, vec!["first", "second"]);
567 assert!(peek.entries.iter().all(|e| e.kind == PeekEntryKind::User));
568 }
569
570 #[test]
571 fn a_person_who_pastes_a_runtime_envelope_is_still_the_person_talking() {
572 // The filter keys on runtime provenance, never on the envelope text.
573 // A composer turn is `ExternalUser`, whose authority is implicit, so
574 // its metadata carries no provenance line — which is what keeps the
575 // second shape here visible even though it is block-for-block what a
576 // handoff looks like.
577 let question = "why did I get <codewhale:runtime_event \
578 kind=\"waiting_for_subagents\" visibility=\"internal\"> \
579 in my transcript?";
580 let pastes = [
581 user(question),
582 Message {
583 role: Role::User,
584 content: vec![
585 text_block(question),
586 text_block(concat!(
587 "<turn_meta>\n",
588 "Current approval mode: on-request\n",
589 "</turn_meta>",
590 )),
591 ],
592 },
593 ];
594
595 for paste in pastes {
596 let peek = build_peek(&session_with(vec![paste]), MAX_PEEK_ENTRIES);
597
598 assert_eq!(peek.entries.len(), 1);
599 assert_eq!(peek.entries[0].kind, PeekEntryKind::User);
600 assert!(peek.entries[0].text.contains("why did I get"));
601 }
602 }
603
604 /// Rebuild a handoff the way the engine's ordinary send path does, with
605 /// the blocks `user_content_blocks` inserts for an `[Attached image: …]`
606 /// line in the payload. Idle completions take that path rather than
607 /// `runtime_handoff_message_with_meta`, so this shape reaches saved
608 /// sessions too.
609 fn with_attachment_blocks(handoff: &Message) -> Message {
610 let (
611 ContentBlock::Text { text: envelope, .. },
612 Some(ContentBlock::Text { text: meta, .. }),
613 ) = (&handoff.content[0], handoff.content.last())
614 else {
615 panic!("handoff should be text-anchored");
616 };
617 Message {
618 role: handoff.role.clone(),
619 content: vec![
620 text_block(envelope),
621 text_block("<image path=\"/tmp/shot.png\">"),
622 ContentBlock::ImageUrl {
623 image_url: codewhale_models::ImageUrlContent {
624 url: "data:image/png;base64,iVBORw0KGgo=".to_string(),
625 },
626 },
627 text_block("</image>"),
628 text_block(meta),
629 ],
630 }
631 }
632
633 #[test]
634 fn a_handoff_that_carried_an_attachment_is_still_recognized() {
635 for (kind, handoff) in runtime_handoffs() {
636 let expanded = with_attachment_blocks(&handoff);
637 let peek = build_peek(
638 &session_with(vec![user("look at this"), expanded]),
639 MAX_PEEK_ENTRIES,
640 );
641
642 assert_eq!(
643 peek.entries.len(),
644 1,
645 "{kind} leaked once its payload mentioned an attachment: {:?}",
646 peek.entries
647 );
648 assert_eq!(peek.entries[0].text, "look at this");
649 }
650 }
651
652 #[test]
653 fn runtime_traffic_does_not_spend_the_entry_budget() {
654 // Filtering after the tail was taken would leave a session with chatty
655 // sub-agents showing two or three lines of conversation out of twelve.
656 let mut messages = Vec::new();
657 for i in 0..MAX_PEEK_ENTRIES {
658 messages.push(user(&format!("message {i}")));
659 messages.push(crate::runtime_handoff::shell_completion_runtime_message(&[]));
660 }
661
662 let peek = build_peek(&session_with(messages), MAX_PEEK_ENTRIES);
663
664 assert_eq!(peek.entries.len(), MAX_PEEK_ENTRIES);
665 assert!(peek.entries[0].text.contains("message 0"));
666 assert!(
667 peek.entries
668 .last()
669 .expect("tail")
670 .text
671 .contains(&format!("message {}", MAX_PEEK_ENTRIES - 1))
672 );
673 }
674
675 #[test]
676 fn the_counters_describe_what_the_pane_can_show() {
677 // The dashboard prints both numbers next to the rows it rendered, so a
678 // count that included hidden runtime traffic would not add up.
679 let mut messages = Vec::new();
680 for i in 0..20 {
681 messages.push(user(&format!("m{i}")));
682 messages.push(crate::runtime_handoff::waiting_for_subagents_runtime_message(1));
683 }
684
685 let peek = build_peek(&session_with(messages), MAX_PEEK_ENTRIES);
686
687 assert_eq!(peek.message_count, 20);
688 assert_eq!(
689 peek.omitted_before + peek.entries.len(),
690 peek.message_count,
691 "omitted + shown must account for every message the peek claims"
692 );
693 }
694 }
695
695 lines RUST