返回 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 /// 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
646 lines RUST