1use crate::handlers::default_excludes_subagents;
7use crate::wire::{
8 GetRequest, GetResponse, GetResult, MessageView, PartKind, PartSummary, ResponsePart,
9 SearchRequest, SearchResponse, SortBy,
10};
11
12pub fn render_subagents_footer(children: &[crate::wire::Session]) -> String {
17 use std::fmt::Write;
18 let mut out = String::new();
19 let _ = writeln!(out);
20 let _ = writeln!(
21 out,
22 "subagents ({}) - pass an id to pond_get(session_id=...):",
23 children.len()
24 );
25 for child in children {
26 let _ = writeln!(out, " {} | {}", child.id, child.source_agent);
27 }
28 out
29}
30
31fn fmt_ts(ts: &chrono::DateTime<chrono::Utc>) -> String {
33 ts.format("%Y-%m-%d %H:%M:%SZ").to_string()
34}
35
36fn opt_name(value: &Option<crate::adapter::extract::Extracted<String>>) -> &str {
39 value.as_deref().map(String::as_str).unwrap_or("?")
40}
41
42fn push_lines(out: &mut String, body: &str, indent: &str) {
46 use std::fmt::Write;
47 for line in body.lines() {
48 let _ = writeln!(out, "{indent}{line}");
49 }
50}
51
52const SEARCH_TRANSCRIPT_BUDGET: usize = 10_000;
58
59pub fn render_search_transcript(response: &SearchResponse, request: &SearchRequest) -> String {
60 use std::fmt::Write;
61 let subagent_note = if default_excludes_subagents(&request.filters) {
62 " Subagent sessions excluded; reach them via pond_sql_query (parent_session_id)."
63 } else {
64 ""
65 };
66 let recency_note = if matches!(request.sort_by, SortBy::Recency) {
67 " Sorted by recency (newest first) - rank is NOT match strength."
68 } else {
69 ""
70 };
71 if response.sessions.is_empty() {
72 if response.searchable_in_scope == 0 {
76 return format!(
77 "pond_search: 0 searchable messages in scope - the filters exclude \
78 everything before retrieval. Widen or drop project/date filters.\
79 {subagent_note}\n"
80 );
81 }
82 let fts_hint = " For exact strings or identifiers, try pond_sql_query: SELECT \
83 message_id, session_id, search_text FROM messages WHERE \
84 contains_tokens(search_text, '...').";
85 return format!(
86 "pond_search: no matches for {:?} across {} searchable messages in \
87 scope.{subagent_note}{fts_hint}\n",
88 request.query, response.searchable_in_scope
89 );
90 }
91 let shown: usize = response.sessions.iter().map(|s| s.matches.len()).sum();
92 let mut out = String::new();
93 let _ = writeln!(
94 out,
95 "pond_search: {} matching messages ({} searchable in scope), showing {} hits from {} \
96 sessions.{}{}",
97 response.matched_total,
98 response.searchable_in_scope,
99 shown,
100 response.sessions.len(),
101 subagent_note,
102 recency_note,
103 );
104 let order = if matches!(request.sort_by, SortBy::Recency) {
105 "newest session first"
106 } else {
107 "ordered by best hit"
108 };
109 let _ = writeln!(
110 out,
111 "key: session rules group hits by session, {order}; within a session, messages are newest-first. \"--- [n] score | role | time | message_id | project | agent | session ---\" delimits each hit + matched text. pond_get <message_id> for full; raise limit for more (no pagination)."
112 );
113 let mut index = 0;
114 let n_sessions = response.sessions.len();
115 for (session_index, session) in response.sessions.iter().enumerate() {
116 let best = session
119 .matches
120 .iter()
121 .map(|hit| hit.score)
122 .fold(0.0_f64, f64::max);
123 let _ = writeln!(out);
124 let _ = writeln!(
125 out,
126 "{}",
127 rule_line(&format!(
128 "session [{}] best {:.2} | {}/{} matched | {} | {} | {}",
129 session_index + 1,
130 best,
131 session.matched_message_count,
132 session.session_messages_count,
133 session.project,
134 session.source_agent,
135 session.session_id,
136 )),
137 );
138 let remaining = SEARCH_TRANSCRIPT_BUDGET.saturating_sub(out.len());
143 let share = remaining / (n_sessions - session_index);
144 let session_start = out.len();
145 let mut rendered = 0usize;
146 for hit in &session.matches {
147 if rendered > 0 && out.len().saturating_sub(session_start) >= share {
148 break;
149 }
150 index += 1;
151 let _ = writeln!(out);
152 let _ = writeln!(
153 out,
154 "{}",
155 rule_line(&format!(
156 "[{index}] {:.2} | {} | {} | {} | {} | {} | {}",
157 hit.score,
158 hit.role.as_str(),
159 fmt_ts(&hit.timestamp),
160 hit.message_id,
161 session.project,
162 session.source_agent,
163 session.session_id,
164 )),
165 );
166 push_lines(&mut out, &hit.text, "");
167 rendered += 1;
168 }
169 let omitted = session.matches.len() - rendered;
173 if omitted > 0 {
174 let _ = writeln!(
175 out,
176 "... {omitted} more match(es) in this session not shown (char budget); \
177 read with session_from=end for the session's latest state"
178 );
179 }
180 }
181 out
182}
183
184pub fn render_get_transcript(response: &GetResponse, request: &GetRequest) -> String {
185 use std::fmt::Write;
186 let session = &response.session;
187 let mut out = String::new();
188 match &response.result {
189 GetResult::Session {
190 messages,
191 before_remaining,
192 after_remaining,
193 } => {
194 let _ = writeln!(
195 out,
196 "pond_get: session {}, {} messages.",
197 session.id,
198 messages.len(),
199 );
200 let _ = writeln!(
201 out,
202 "key: \"--- [n] role | time | message_id ---\" delimits each message; \"->\" tool call, \"<-\" result; pond_get message_id=<id> to expand any tool body. Page with session_before_message_id / session_after_message_id."
203 );
204 if *before_remaining > 0
206 && let Some(first) = messages.first()
207 {
208 let _ = writeln!(
209 out,
210 "... {before_remaining} earlier messages; pass session_before_message_id={} to page up",
211 first.id,
212 );
213 }
214 for (idx, message) in messages.iter().enumerate() {
215 let _ = writeln!(out);
216 render_message(
217 &mut out,
218 idx + 1,
219 message,
220 None,
221 &message.parts_summary,
222 false,
223 );
224 }
225 let _ = writeln!(out);
226 let _ = writeln!(
227 out,
228 "session {} | {} | {}",
229 session.id, session.source_agent, session.project,
230 );
231 if *after_remaining > 0
233 && let Some(last) = messages.last()
234 {
235 let _ = writeln!(
236 out,
237 "... {after_remaining} later messages; pass session_after_message_id={} to page down",
238 last.id,
239 );
240 }
241 }
242 GetResult::Message {
243 target,
244 target_parts,
245 target_parts_remaining,
246 siblings,
247 } => {
248 let _ = writeln!(
249 out,
250 "pond_get: thread around {} in session {} (context -{}/+{}).",
251 target.id,
252 session.id,
253 request.message_context_before,
254 request.message_context_after,
255 );
256 let _ = writeln!(
257 out,
258 "key: \"--- [n] role | time | message_id ---\" delimits each message; \">\" = the one you requested; \"->\" tool call, \"<-\" result. pond_get message_id=<id> to expand any line."
259 );
260 let mut thread: Vec<(&MessageView, bool)> =
267 siblings.iter().map(|view| (view, false)).collect();
268 thread.push((target, true));
269 thread.sort_by(|a, b| {
270 a.0.timestamp
271 .cmp(&b.0.timestamp)
272 .then_with(|| a.0.id.cmp(&b.0.id))
273 });
274 thread.retain(|(view, is_target)| *is_target || message_has_content(view));
275 for (idx, (view, is_target)) in thread.iter().enumerate() {
276 let _ = writeln!(out);
277 let parts: Option<&[ResponsePart]> = is_target.then_some(target_parts.as_slice());
280 render_message(
281 &mut out,
282 idx + 1,
283 view,
284 parts,
285 &view.parts_summary,
286 *is_target,
287 );
288 }
289 let _ = writeln!(out);
290 let _ = writeln!(
291 out,
292 "session {} | {} | {}",
293 session.id, session.source_agent, session.project,
294 );
295 if *target_parts_remaining > 0 {
296 let _ = writeln!(
297 out,
298 "... {} more parts of {} omitted (response budget)",
299 target_parts_remaining, target.id,
300 );
301 }
302 }
303 }
304 out
305}
306
307fn message_has_content(view: &MessageView) -> bool {
311 view.text.as_deref().is_some_and(|t| !t.trim().is_empty())
312 || view
313 .content
314 .as_deref()
315 .is_some_and(|c| !c.trim().is_empty())
316 || !view.parts_summary.is_empty()
317}
318
319const RULE_WIDTH: usize = 72;
321
322fn rule_line(inner: &str) -> String {
326 let head = format!("--- {inner} ");
327 let pad = RULE_WIDTH.saturating_sub(head.chars().count()).max(3);
328 format!("{head}{}", "-".repeat(pad))
329}
330
331fn render_message(
336 out: &mut String,
337 index: usize,
338 view: &MessageView,
339 parts: Option<&[ResponsePart]>,
340 summary: &[PartSummary],
341 is_target: bool,
342) {
343 use std::fmt::Write;
344 let marker = if is_target { "> " } else { "" };
345 let _ = writeln!(
346 out,
347 "{}",
348 rule_line(&format!(
349 "[{index}] {marker}{} | {} | {}",
350 view.role.as_str(),
351 fmt_ts(&view.timestamp),
352 view.id,
353 )),
354 );
355 if let Some(text) = &view.text {
356 push_lines(out, text, "");
357 }
358 if let Some(content) = &view.content {
359 push_lines(out, content, "");
360 }
361 match parts {
362 Some(parts) => {
363 for part in parts {
364 render_part_full(out, part);
365 }
366 }
367 None => {
368 for part in summary {
369 render_part_summary(out, part);
370 }
371 }
372 }
373}
374
375fn render_part_full(out: &mut String, part: &ResponsePart) {
376 use std::fmt::Write;
377 match &part.kind {
378 PartKind::Text { text } => {
379 if let Some(text) = text {
380 push_lines(out, text, "");
381 }
382 }
383 PartKind::Reasoning { text } => {
384 let _ = writeln!(out, " (reasoning)");
385 if let Some(text) = text {
386 push_lines(out, text, " ");
387 }
388 }
389 PartKind::ToolCall {
390 name,
391 call_id,
392 params,
393 ..
394 } => {
395 let _ = writeln!(out, " -> {} [{}]", opt_name(name), opt_name(call_id));
396 push_lines(out, &value_to_text(params), " ");
397 }
398 PartKind::ToolResult {
399 name,
400 call_id,
401 is_failure,
402 result,
403 } => {
404 let status = if *is_failure { "failed" } else { "ok" };
405 let _ = writeln!(
406 out,
407 " <- {} [{}] ({status})",
408 opt_name(name),
409 opt_name(call_id),
410 );
411 push_lines(out, &value_to_text(result), " ");
412 }
413 PartKind::File {
414 media_type,
415 file_name,
416 ..
417 } => {
418 let label = file_name
419 .as_deref()
420 .or(media_type.as_deref())
421 .unwrap_or("file");
422 let _ = writeln!(out, " [file {label}]");
423 }
424 PartKind::ToolApprovalRequest { approval_id, .. } => {
425 let _ = writeln!(out, " [approval request {approval_id}]");
426 }
427 PartKind::ToolApprovalResponse {
428 approval_id,
429 approved,
430 ..
431 } => {
432 let verb = if *approved { "approved" } else { "denied" };
433 let _ = writeln!(out, " [approval {approval_id} {verb}]");
434 }
435 }
436}
437
438fn render_part_summary(out: &mut String, summary: &PartSummary) {
439 use std::fmt::Write;
440 let label = summary.label.as_deref().unwrap_or("");
441 let call = summary
442 .call_id
443 .as_deref()
444 .map(|id| format!(" [{id}]"))
445 .unwrap_or_default();
446 match summary.kind.as_str() {
447 "tool_call" => {
448 let _ = writeln!(out, " -> {label}{call}");
449 }
450 "tool_result" => {
451 let _ = writeln!(out, " <- {label}{call}");
452 }
453 "file" => {
454 let _ = writeln!(out, " [file {label}]");
455 }
456 other => {
457 let _ = writeln!(out, " [{other} {label}]");
458 }
459 }
460}
461
462fn value_to_text(value: &serde_json::Value) -> String {
465 match value {
466 serde_json::Value::String(text) => text.clone(),
467 serde_json::Value::Null => String::new(),
468 other => serde_json::to_string(other).unwrap_or_default(),
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 #![allow(clippy::expect_used, clippy::unwrap_used)]
475
476 use super::*;
477 use crate::wire::{Role, SearchFilters, SearchModeWire, SearchResult, SessionFrom};
478
479 #[test]
480 fn get_transcript_marks_target_and_renders_tool_parts() {
481 let ts = chrono::DateTime::from_timestamp(0, 0).unwrap();
482 let tool_call: ResponsePart = serde_json::from_value(serde_json::json!({
483 "id": "p1", "ordinal": 0, "provenance": "conversational",
484 "type": "tool_call", "name": "Bash", "call_id": "toolu_x",
485 "params": { "command": "ls" }, "provider_executed": false,
486 }))
487 .unwrap();
488 let tool_result: ResponsePart = serde_json::from_value(serde_json::json!({
489 "id": "p2", "ordinal": 1, "provenance": "conversational",
490 "type": "tool_result", "name": "Bash", "call_id": "toolu_x",
491 "is_failure": false, "result": "file.txt",
492 }))
493 .unwrap();
494 let target = MessageView {
495 id: "m1".to_owned(),
496 role: crate::wire::Role::Assistant,
497 timestamp: ts,
498 text: Some("Let me list files.".to_owned()),
499 content: None,
500 parts_summary: Vec::new(),
501 };
502 let response = GetResponse {
503 session: crate::wire::GetSession {
504 id: "s1".to_owned(),
505 source_agent: "claude-code".to_owned(),
506 project: "/p".to_owned(),
507 created_at: ts,
508 },
509 result: GetResult::Message {
510 target,
511 target_parts: vec![tool_call, tool_result],
512 target_parts_remaining: 0,
513 siblings: Vec::new(),
514 },
515 };
516 let request = GetRequest {
517 protocol_version: crate::PROTOCOL_VERSION,
518 namespace: None,
519 session_id: None,
520 message_id: Some("m1".to_owned()),
521 session_limit: 20,
522 session_from: SessionFrom::default(),
523 session_after_message_id: None,
524 session_before_message_id: None,
525 message_context_before: 3,
526 message_context_after: 3,
527 };
528
529 let transcript = crate::render::render_get_transcript(&response, &request);
530 assert!(transcript.contains("--- [1] > assistant | 1970-01-01 00:00:00Z | m1 ---"));
531 assert!(transcript.contains("Let me list files."));
532 assert!(transcript.contains(" -> Bash [toolu_x]"));
533 assert!(transcript.contains(" <- Bash [toolu_x] (ok)"));
534 assert!(transcript.contains("session s1 | claude-code | /p"));
535 }
536
537 #[test]
538 fn search_transcript_renders_header_and_hits() {
539 let response = SearchResponse {
540 sessions: vec![crate::wire::SearchSession {
541 session_id: "s1".to_owned(),
542 project: "pond".to_owned(),
543 source_agent: "claude-code".to_owned(),
544 session_messages_count: 2,
545 matched_message_count: 1,
546 matches: vec![SearchResult {
547 message_id: "m1".to_owned(),
548 role: Role::User,
549 timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(),
550 text: "hello\nworld".to_owned(),
551 score: 1.0,
552 parts_summary: Vec::new(),
553 }],
554 }],
555 matched_total: 1,
556 searchable_in_scope: 2,
557 has_more: false,
558 };
559 let request = SearchRequest {
560 protocol_version: crate::PROTOCOL_VERSION,
561 namespace: None,
562 query: "hi".to_owned(),
563 mode: SearchModeWire::Vector,
564 sort_by: SortBy::Relevance,
565 filters: SearchFilters::default(),
566 limit: 10,
567 };
568
569 let transcript = crate::render::render_search_transcript(&response, &request);
570 assert!(transcript.starts_with(
571 "pond_search: 1 matching messages (2 searchable in scope), showing 1 hits from 1 \
572 sessions."
573 ));
574 assert!(
575 transcript.contains("key: session rules group hits by session, ordered by best hit")
576 );
577 assert!(
578 transcript
579 .contains("--- session [1] best 1.00 | 1/2 matched | pond | claude-code | s1")
580 );
581 assert!(
584 transcript.contains(
585 "--- [1] 1.00 | user | 1970-01-01 00:00:00Z | m1 | pond | claude-code | s1"
586 )
587 );
588 assert!(transcript.contains("hello\nworld"));
590 }
591
592 #[test]
593 fn search_transcript_budget_keeps_every_session_and_footers_the_truncated_one() {
594 let big = "x".repeat(600);
595 let hit = |id: usize| SearchResult {
596 message_id: format!("m{id}"),
597 role: Role::Assistant,
598 timestamp: chrono::DateTime::from_timestamp(id as i64, 0).unwrap(),
599 text: big.clone(),
600 score: 0.9,
601 parts_summary: Vec::new(),
602 };
603 let session = |id: &str, matches: Vec<SearchResult>| crate::wire::SearchSession {
604 session_id: id.to_owned(),
605 project: "pond".to_owned(),
606 source_agent: "claude-code".to_owned(),
607 session_messages_count: 100,
608 matched_message_count: matches.len(),
609 matches,
610 };
611 let mut sessions = vec![session("fat", (0..40).map(hit).collect())];
614 for s in 1..=5 {
615 sessions.push(session(&format!("s{s}"), vec![hit(s * 1000)]));
616 }
617 let response = SearchResponse {
618 sessions,
619 matched_total: 45,
620 searchable_in_scope: 200,
621 has_more: false,
622 };
623 let request = SearchRequest {
624 protocol_version: crate::PROTOCOL_VERSION,
625 namespace: None,
626 query: "x".to_owned(),
627 mode: SearchModeWire::Vector,
628 sort_by: SortBy::Relevance,
629 filters: SearchFilters::default(),
630 limit: 10,
631 };
632 let transcript = crate::render::render_search_transcript(&response, &request);
633
634 assert!(
637 transcript.len() < SEARCH_TRANSCRIPT_BUDGET + 3_000,
638 "transcript {} exceeds the soft budget",
639 transcript.len(),
640 );
641 for id in ["fat", "s1", "s2", "s3", "s4", "s5"] {
643 assert!(
644 transcript.contains(&format!("| {id}\n"))
645 || transcript.contains(&format!("| {id} ")),
646 "session {id} did not render",
647 );
648 }
649 assert!(transcript.contains("more match(es) in this session not shown (char budget)"));
652 assert!(transcript.contains("session_from=end"));
653 }
654}