| 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 | /// Conservative and shape-based: it does not try to understand the text, only |
| 207 | /// to recognise the handful of forms secrets usually take in a transcript. |
| 208 | /// Over-redacting a peek line is cheap; leaking a key over a LAN is not. |
| 209 | #[must_use] |
| 210 | pub fn redact(text: &str) -> (String, bool) { |
| 211 | let mut out = String::with_capacity(text.len()); |
| 212 | let mut redacted = false; |
| 213 | |
| 214 | for token in text.split_inclusive(char::is_whitespace) { |
| 215 | let trimmed = token.trim_end(); |
| 216 | let trailing = &token[trimmed.len()..]; |
| 217 | if looks_like_secret(trimmed) { |
| 218 | out.push_str(REDACTED_PLACEHOLDER); |
| 219 | out.push_str(trailing); |
| 220 | redacted = true; |
| 221 | } else if let Some(masked) = mask_assignment(trimmed) { |
| 222 | out.push_str(&masked); |
| 223 | out.push_str(trailing); |
| 224 | redacted = true; |
| 225 | } else { |
| 226 | out.push_str(token); |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | (out, redacted) |
| 231 | } |
| 232 | |
| 233 | /// Known credential prefixes plus long opaque runs. |
| 234 | fn looks_like_secret(token: &str) -> bool { |
| 235 | const PREFIXES: &[&str] = &[ |
| 236 | "sk-", |
| 237 | "sk_", |
| 238 | "pk_", |
| 239 | "ghp_", |
| 240 | "gho_", |
| 241 | "ghu_", |
| 242 | "ghs_", |
| 243 | "github_pat_", |
| 244 | "xoxb-", |
| 245 | "xoxp-", |
| 246 | "AKIA", |
| 247 | "ASIA", |
| 248 | "AIza", |
| 249 | "hf_", |
| 250 | "Bearer", |
| 251 | ]; |
| 252 | if PREFIXES |
| 253 | .iter() |
| 254 | .any(|prefix| token.len() > prefix.len() && token.starts_with(prefix)) |
| 255 | { |
| 256 | return true; |
| 257 | } |
| 258 | // A long unbroken run of base64/hex-ish characters is almost never prose. |
| 259 | token.len() >= 32 |
| 260 | && token |
| 261 | .chars() |
| 262 | .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' || c == '_') |
| 263 | && token.chars().any(|c| c.is_ascii_digit()) |
| 264 | && token.chars().any(|c| c.is_ascii_alphabetic()) |
| 265 | } |
| 266 | |
| 267 | /// `key=value`, `token: value`, `--password value` style assignments. |
| 268 | fn mask_assignment(token: &str) -> Option<String> { |
| 269 | const KEYS: &[&str] = &[ |
| 270 | "api_key", |
| 271 | "apikey", |
| 272 | "api-key", |
| 273 | "token", |
| 274 | "secret", |
| 275 | "password", |
| 276 | "passwd", |
| 277 | "authorization", |
| 278 | "auth", |
| 279 | "credential", |
| 280 | ]; |
| 281 | let (name, sep_index) = token |
| 282 | .find('=') |
| 283 | .map(|i| (&token[..i], i)) |
| 284 | .or_else(|| token.find(':').map(|i| (&token[..i], i)))?; |
| 285 | let normalized = name.trim_start_matches('-').to_ascii_lowercase(); |
| 286 | if !KEYS.contains(&normalized.as_str()) { |
| 287 | return None; |
| 288 | } |
| 289 | if token[sep_index + 1..].trim().is_empty() { |
| 290 | return None; |
| 291 | } |
| 292 | Some(format!( |
| 293 | "{}{}{REDACTED_PLACEHOLDER}", |
| 294 | name, |
| 295 | &token[sep_index..=sep_index] |
| 296 | )) |
| 297 | } |
| 298 | |
| 299 | #[cfg(test)] |
| 300 | mod tests { |
| 301 | use super::*; |
| 302 | use crate::session_manager::create_saved_session_with_id_and_mode; |
| 303 | use codewhale_models::Message; |
| 304 | use codewhale_models::Role; |
| 305 | |
| 306 | fn text_block(text: &str) -> ContentBlock { |
| 307 | ContentBlock::Text { |
| 308 | text: text.to_string(), |
| 309 | cache_control: None, |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | fn session_with(messages: Vec<Message>) -> SavedSession { |
| 314 | create_saved_session_with_id_and_mode( |
| 315 | "peek-session".to_string(), |
| 316 | &messages, |
| 317 | "deepseek-chat", |
| 318 | std::path::Path::new("/repo"), |
| 319 | 10, |
| 320 | None, |
| 321 | Some("agent"), |
| 322 | ) |
| 323 | } |
| 324 | |
| 325 | fn user(text: &str) -> Message { |
| 326 | Message { |
| 327 | role: Role::User, |
| 328 | content: vec![text_block(text)], |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | #[test] |
| 333 | fn peek_is_bounded_in_entries_and_reports_what_it_dropped() { |
| 334 | let messages: Vec<Message> = (0..40).map(|i| user(&format!("message {i}"))).collect(); |
| 335 | let peek = build_peek(&session_with(messages), MAX_PEEK_ENTRIES); |
| 336 | |
| 337 | assert_eq!(peek.entries.len(), MAX_PEEK_ENTRIES); |
| 338 | assert_eq!(peek.message_count, 40); |
| 339 | assert_eq!(peek.omitted_before, 40 - MAX_PEEK_ENTRIES); |
| 340 | assert!( |
| 341 | peek.entries.last().expect("tail").text.contains("39"), |
| 342 | "the peek must be the tail, not the head" |
| 343 | ); |
| 344 | } |
| 345 | |
| 346 | #[test] |
| 347 | fn a_request_for_more_than_the_cap_still_gets_the_cap() { |
| 348 | let messages: Vec<Message> = (0..100).map(|i| user(&format!("m{i}"))).collect(); |
| 349 | let peek = build_peek(&session_with(messages), usize::MAX); |
| 350 | assert_eq!(peek.entries.len(), MAX_PEEK_ENTRIES); |
| 351 | } |
| 352 | |
| 353 | #[test] |
| 354 | fn long_entries_are_truncated_and_flagged() { |
| 355 | let peek = build_peek(&session_with(vec![user(&"x".repeat(5_000))]), 4); |
| 356 | let entry = &peek.entries[0]; |
| 357 | assert!(entry.truncated); |
| 358 | assert!(entry.text.chars().count() <= MAX_ENTRY_CHARS); |
| 359 | } |
| 360 | |
| 361 | #[test] |
| 362 | fn credentials_are_redacted_out_of_peek_text() { |
| 363 | for secret in [ |
| 364 | "sk-abcdefghijklmnopqrstuvwxyz123456", |
| 365 | "ghp_abcdefghijklmnopqrstuvwxyz1234", |
| 366 | "AKIAIOSFODNN7EXAMPLE", |
| 367 | ] { |
| 368 | let peek = build_peek(&session_with(vec![user(&format!("here: {secret}"))]), 4); |
| 369 | let entry = &peek.entries[0]; |
| 370 | assert!(entry.redacted, "{secret} should have been redacted"); |
| 371 | assert!( |
| 372 | !entry.text.contains(secret), |
| 373 | "peek leaked {secret}: {}", |
| 374 | entry.text |
| 375 | ); |
| 376 | assert!(entry.text.contains(REDACTED_PLACEHOLDER)); |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | #[test] |
| 381 | fn assignment_style_secrets_are_masked_but_keep_their_key() { |
| 382 | let (masked, redacted) = redact("api_key=hunter2 and password:swordfish"); |
| 383 | assert!(redacted); |
| 384 | assert!(masked.contains("api_key=")); |
| 385 | assert!(!masked.contains("hunter2")); |
| 386 | assert!(!masked.contains("swordfish")); |
| 387 | } |
| 388 | |
| 389 | #[test] |
| 390 | fn ordinary_prose_is_not_redacted() { |
| 391 | let (out, redacted) = redact("Please refactor the lane registry and update the docs."); |
| 392 | assert!(!redacted); |
| 393 | assert_eq!( |
| 394 | out, |
| 395 | "Please refactor the lane registry and update the docs." |
| 396 | ); |
| 397 | } |
| 398 | |
| 399 | #[test] |
| 400 | fn tool_payloads_are_summarised_never_inlined() { |
| 401 | let message = Message { |
| 402 | role: Role::Assistant, |
| 403 | content: vec![ |
| 404 | ContentBlock::ToolUse { |
| 405 | id: "call-1".to_string(), |
| 406 | name: "read_file".to_string(), |
| 407 | input: serde_json::json!({ "path": "/etc/shadow" }), |
| 408 | caller: None, |
| 409 | thought_signature: None, |
| 410 | }, |
| 411 | ContentBlock::ToolResult { |
| 412 | tool_use_id: "call-1".to_string(), |
| 413 | content: "root:$6$verysecrethash".to_string(), |
| 414 | is_error: None, |
| 415 | content_blocks: None, |
| 416 | }, |
| 417 | ], |
| 418 | }; |
| 419 | let peek = build_peek(&session_with(vec![message]), 4); |
| 420 | let entry = &peek.entries[0]; |
| 421 | |
| 422 | assert_eq!(entry.kind, PeekEntryKind::Tool); |
| 423 | assert!(entry.text.contains("[tool call: read_file]")); |
| 424 | assert!(entry.text.contains("[tool result:")); |
| 425 | assert!( |
| 426 | !entry.text.contains("verysecrethash"), |
| 427 | "tool output must never be inlined into a peek: {}", |
| 428 | entry.text |
| 429 | ); |
| 430 | assert!( |
| 431 | !entry.text.contains("/etc/shadow"), |
| 432 | "tool input must not be inlined either: {}", |
| 433 | entry.text |
| 434 | ); |
| 435 | } |
| 436 | |
| 437 | #[test] |
| 438 | fn a_peek_never_claims_to_be_live() { |
| 439 | let peek = build_peek(&session_with(vec![user("hello")]), 4); |
| 440 | assert!(!peek.live, "a saved session is a recording, never live"); |
| 441 | let json = serde_json::to_value(&peek).expect("serialize"); |
| 442 | for forbidden in ["status", "running", "active", "turn"] { |
| 443 | assert!( |
| 444 | json.get(forbidden).is_none(), |
| 445 | "peek payload must not carry a `{forbidden}` field a client could read as live state" |
| 446 | ); |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | #[test] |
| 451 | fn archive_state_rides_along_so_the_dashboard_need_not_guess() { |
| 452 | let mut session = session_with(vec![user("hello")]); |
| 453 | session.metadata.archived = true; |
| 454 | assert!(build_peek(&session, 4).archived); |
| 455 | } |
| 456 | |
| 457 | /// Every runtime handoff shape that can reach a saved session, including |
| 458 | /// the restore checkpoints a post-resume save persists. |
| 459 | fn runtime_handoffs() -> Vec<(&'static str, Message)> { |
| 460 | let waiting = crate::runtime_handoff::waiting_for_subagents_runtime_message(2); |
| 461 | let restored = |
| 462 | crate::runtime_handoff::project_messages_for_restore(std::slice::from_ref(&waiting)); |
| 463 | vec![ |
| 464 | ("waiting_for_subagents", waiting), |
| 465 | ( |
| 466 | "background_shell_completion", |
| 467 | crate::runtime_handoff::shell_completion_runtime_message(&[]), |
| 468 | ), |
| 469 | ( |
| 470 | "restored_checkpoint", |
| 471 | restored.into_iter().next().expect("projected"), |
| 472 | ), |
| 473 | ] |
| 474 | } |
| 475 | |
| 476 | #[test] |
| 477 | fn internal_runtime_events_are_absent_from_a_peek() { |
| 478 | for (kind, handoff) in runtime_handoffs() { |
| 479 | let peek = build_peek( |
| 480 | &session_with(vec![user("ship the release"), handoff]), |
| 481 | MAX_PEEK_ENTRIES, |
| 482 | ); |
| 483 | |
| 484 | assert_eq!( |
| 485 | peek.entries.len(), |
| 486 | 1, |
| 487 | "{kind} was rendered as conversation: {:?}", |
| 488 | peek.entries |
| 489 | ); |
| 490 | assert_eq!(peek.entries[0].text, "ship the release"); |
| 491 | for entry in &peek.entries { |
| 492 | assert!( |
| 493 | !entry.text.contains("<codewhale:runtime_event"), |
| 494 | "{kind} leaked its envelope into a peek entry: {}", |
| 495 | entry.text |
| 496 | ); |
| 497 | assert!( |
| 498 | !entry.text.contains("[Codewhale restored"), |
| 499 | "{kind} leaked a restore checkpoint into a peek entry: {}", |
| 500 | entry.text |
| 501 | ); |
| 502 | } |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | #[test] |
| 507 | fn real_user_messages_survive_the_runtime_filter() { |
| 508 | let mut messages = vec![user("first")]; |
| 509 | for (_, handoff) in runtime_handoffs() { |
| 510 | messages.push(handoff); |
| 511 | } |
| 512 | messages.push(user("second")); |
| 513 | |
| 514 | let peek = build_peek(&session_with(messages), MAX_PEEK_ENTRIES); |
| 515 | |
| 516 | let texts: Vec<&str> = peek.entries.iter().map(|e| e.text.as_str()).collect(); |
| 517 | assert_eq!(texts, vec!["first", "second"]); |
| 518 | assert!(peek.entries.iter().all(|e| e.kind == PeekEntryKind::User)); |
| 519 | } |
| 520 | |
| 521 | #[test] |
| 522 | fn a_person_who_pastes_a_runtime_envelope_is_still_the_person_talking() { |
| 523 | // The filter keys on runtime provenance, never on the envelope text. |
| 524 | // A composer turn is `ExternalUser`, whose authority is implicit, so |
| 525 | // its metadata carries no provenance line — which is what keeps the |
| 526 | // second shape here visible even though it is block-for-block what a |
| 527 | // handoff looks like. |
| 528 | let question = "why did I get <codewhale:runtime_event \ |
| 529 | kind=\"waiting_for_subagents\" visibility=\"internal\"> \ |
| 530 | in my transcript?"; |
| 531 | let pastes = [ |
| 532 | user(question), |
| 533 | Message { |
| 534 | role: Role::User, |
| 535 | content: vec![ |
| 536 | text_block(question), |
| 537 | text_block(concat!( |
| 538 | "<turn_meta>\n", |
| 539 | "Current approval mode: on-request\n", |
| 540 | "</turn_meta>", |
| 541 | )), |
| 542 | ], |
| 543 | }, |
| 544 | ]; |
| 545 | |
| 546 | for paste in pastes { |
| 547 | let peek = build_peek(&session_with(vec![paste]), MAX_PEEK_ENTRIES); |
| 548 | |
| 549 | assert_eq!(peek.entries.len(), 1); |
| 550 | assert_eq!(peek.entries[0].kind, PeekEntryKind::User); |
| 551 | assert!(peek.entries[0].text.contains("why did I get")); |
| 552 | } |
| 553 | } |
| 554 | |
| 555 | /// Rebuild a handoff the way the engine's ordinary send path does, with |
| 556 | /// the blocks `user_content_blocks` inserts for an `[Attached image: …]` |
| 557 | /// line in the payload. Idle completions take that path rather than |
| 558 | /// `runtime_handoff_message_with_meta`, so this shape reaches saved |
| 559 | /// sessions too. |
| 560 | fn with_attachment_blocks(handoff: &Message) -> Message { |
| 561 | let ( |
| 562 | ContentBlock::Text { text: envelope, .. }, |
| 563 | Some(ContentBlock::Text { text: meta, .. }), |
| 564 | ) = (&handoff.content[0], handoff.content.last()) |
| 565 | else { |
| 566 | panic!("handoff should be text-anchored"); |
| 567 | }; |
| 568 | Message { |
| 569 | role: handoff.role.clone(), |
| 570 | content: vec![ |
| 571 | text_block(envelope), |
| 572 | text_block("<image path=\"/tmp/shot.png\">"), |
| 573 | ContentBlock::ImageUrl { |
| 574 | image_url: codewhale_models::ImageUrlContent { |
| 575 | url: "data:image/png;base64,iVBORw0KGgo=".to_string(), |
| 576 | }, |
| 577 | }, |
| 578 | text_block("</image>"), |
| 579 | text_block(meta), |
| 580 | ], |
| 581 | } |
| 582 | } |
| 583 | |
| 584 | #[test] |
| 585 | fn a_handoff_that_carried_an_attachment_is_still_recognized() { |
| 586 | for (kind, handoff) in runtime_handoffs() { |
| 587 | let expanded = with_attachment_blocks(&handoff); |
| 588 | let peek = build_peek( |
| 589 | &session_with(vec![user("look at this"), expanded]), |
| 590 | MAX_PEEK_ENTRIES, |
| 591 | ); |
| 592 | |
| 593 | assert_eq!( |
| 594 | peek.entries.len(), |
| 595 | 1, |
| 596 | "{kind} leaked once its payload mentioned an attachment: {:?}", |
| 597 | peek.entries |
| 598 | ); |
| 599 | assert_eq!(peek.entries[0].text, "look at this"); |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | #[test] |
| 604 | fn runtime_traffic_does_not_spend_the_entry_budget() { |
| 605 | // Filtering after the tail was taken would leave a session with chatty |
| 606 | // sub-agents showing two or three lines of conversation out of twelve. |
| 607 | let mut messages = Vec::new(); |
| 608 | for i in 0..MAX_PEEK_ENTRIES { |
| 609 | messages.push(user(&format!("message {i}"))); |
| 610 | messages.push(crate::runtime_handoff::shell_completion_runtime_message(&[])); |
| 611 | } |
| 612 | |
| 613 | let peek = build_peek(&session_with(messages), MAX_PEEK_ENTRIES); |
| 614 | |
| 615 | assert_eq!(peek.entries.len(), MAX_PEEK_ENTRIES); |
| 616 | assert!(peek.entries[0].text.contains("message 0")); |
| 617 | assert!( |
| 618 | peek.entries |
| 619 | .last() |
| 620 | .expect("tail") |
| 621 | .text |
| 622 | .contains(&format!("message {}", MAX_PEEK_ENTRIES - 1)) |
| 623 | ); |
| 624 | } |
| 625 | |
| 626 | #[test] |
| 627 | fn the_counters_describe_what_the_pane_can_show() { |
| 628 | // The dashboard prints both numbers next to the rows it rendered, so a |
| 629 | // count that included hidden runtime traffic would not add up. |
| 630 | let mut messages = Vec::new(); |
| 631 | for i in 0..20 { |
| 632 | messages.push(user(&format!("m{i}"))); |
| 633 | messages.push(crate::runtime_handoff::waiting_for_subagents_runtime_message(1)); |
| 634 | } |
| 635 | |
| 636 | let peek = build_peek(&session_with(messages), MAX_PEEK_ENTRIES); |
| 637 | |
| 638 | assert_eq!(peek.message_count, 20); |
| 639 | assert_eq!( |
| 640 | peek.omitted_before + peek.entries.len(), |
| 641 | peek.message_count, |
| 642 | "omitted + shown must account for every message the peek claims" |
| 643 | ); |
| 644 | } |
| 645 | } |
| 646 |