Skip to main content

pond/
render.rs

1//! Canonical text-transcript rendering for `pond_search` / `pond_get`
2//! responses, shared by the MCP transport and the `pond` CLI so both surfaces
3//! emit one identical readable format (spec.md#protocol). The structured
4//! HTTP/JSON path renders nothing here; this is the plain-text view.
5
6use crate::handlers::default_excludes_subagents;
7use crate::wire::{
8    GetRequest, GetResponse, GetResult, MessageView, PartKind, PartSummary, ResponsePart,
9    SearchRequest, SearchResponse, SortBy,
10};
11
12/// Footer for a `pond_get` session response listing the session's spawn-only
13/// subagents. Each subagent is its own session (spec.md#datasets) addressable
14/// by the printed id, so the caller can open any with `pond_get(session_id)`;
15/// without this they are invisible from the MCP surface.
16pub 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
31/// `YYYY-MM-DD HH:MM:SSZ` - compact, sortable, timezone-explicit.
32fn fmt_ts(ts: &chrono::DateTime<chrono::Utc>) -> String {
33    ts.format("%Y-%m-%d %H:%M:%SZ").to_string()
34}
35
36/// Inner string of an `Extracted<String>` option, or `?` when the source
37/// carried none (spec.md#model-no-synthesis: absence is real, not a blank).
38fn opt_name(value: &Option<crate::adapter::extract::Extracted<String>>) -> &str {
39    value.as_deref().map(String::as_str).unwrap_or("?")
40}
41
42/// Append each line of `body` to `out`, so escaped `\n` in stored text
43/// renders as real line breaks. A trailing blank line in the source is
44/// dropped (lines() already does this).
45fn 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
52/// Char ceiling for a rendered `pond_search` transcript (spec.md#search).
53/// Enforced as per-session fair-share truncation that always renders every
54/// returned session's top hit - never a whole-response guillotine. The
55/// structured response (HTTP) is unaffected; this bounds only the agent
56/// transcript. Soft: a single session's header + one hit may nudge past it.
57const 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        // spec.md#search-absence-honesty: name the scope size and the
73        // recovery path - a zero-hit response must distinguish "nothing
74        // relevant exists" from "the filters excluded everything".
75        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        // Highest score among the session's matches. Not `matches.first()`:
117        // matches render newest-first, so the first need not be the best.
118        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        // Even share of the remaining budget across the sessions still to
139        // render, so all of them surface at least their newest hit (never a
140        // whole-response guillotine). Extra hits in a session stop once its
141        // share is spent; the first hit always renders.
142        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        // Intra-session supersession signal (spec.md#search): when the char
170        // budget cut this session's matches short, point the agent at the
171        // session's latest state, which may revise these older hits.
172        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            // Top marker: earlier messages precede this page (page up).
205            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            // Bottom marker: later messages follow this page (page down).
232            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            // Interleave target with siblings, ordered by (timestamp, id) to
261            // match storage - codex writes many messages at the same
262            // timestamp, so the id is the real tiebreak (a bare timestamp
263            // sort scrambles them). Drop context siblings with nothing to
264            // render (carrier turns with no text/content); the requested
265            // target always stays, even if empty.
266            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                // Only the target carries full parts; siblings render as
278                // conversational text + one-line summaries.
279                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
307/// Whether a message view has anything to render below its header: real
308/// text/content or a one-line part summary. Used to drop empty carrier
309/// turns from message-scope context.
310fn 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
319/// Target column width for a delimiter-rule header.
320const RULE_WIDTH: usize = 72;
321
322/// Wrap `inner` as a delimiter rule: `--- {inner} ----...` padded to
323/// [`RULE_WIDTH`] (always at least a 3-dash tail when `inner` is already
324/// wide). Used for both search hits and get message headers.
325fn 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
331/// One message block: an indexed `--- [n] role | time | id ---` delimiter
332/// rule (unambiguous even when the body has blank lines or `##` headings),
333/// then text/content as real lines, then parts - full bodies when `parts`
334/// is present, else one-line summaries.
335fn 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
462/// Render a tool param/result `Value` for the transcript: a JSON string
463/// shows as its text; anything else as compact JSON. `null` shows nothing.
464fn 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        // Hit lines stay flat and indexed so callers can still extract
582        // message_id from the same delimiter shape.
583        assert!(
584            transcript.contains(
585                "--- [1] 1.00 | user | 1970-01-01 00:00:00Z | m1 | pond | claude-code | s1"
586            )
587        );
588        // Stored "\n" renders as a real line break, not an escape.
589        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        // One fat session whose matches alone exceed the budget, plus five
612        // more that must each still surface their top hit.
613        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        // Bounded near the budget (soft: each session's guaranteed top hit
635        // can nudge its share, so allow a per-session overshoot margin).
636        assert!(
637            transcript.len() < SEARCH_TRANSCRIPT_BUDGET + 3_000,
638            "transcript {} exceeds the soft budget",
639            transcript.len(),
640        );
641        // Never a whole-response guillotine: every returned session renders.
642        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        // The fat session was cut short -> supersession footer pointing at
650        // the session's latest state.
651        assert!(transcript.contains("more match(es) in this session not shown (char budget)"));
652        assert!(transcript.contains("session_from=end"));
653    }
654}