Skip to main content

pond/
render.rs

1//! Canonical text-transcript rendering for `pond_search` / `pond_get_session` /
2//! `pond_get_message`
3//! responses, shared by the MCP transport and the `pond` CLI so both surfaces
4//! emit one identical readable format (spec.md#protocol). The structured
5//! HTTP/JSON path renders nothing here; this is the plain-text view.
6
7use crate::handlers::default_excludes_subagents;
8use crate::wire::{
9    GetResponse, GetResult, MessageView, PartKind, PartSummary, ResponsePart, SearchModeWire,
10    SearchRequest, SearchResponse, SortBy,
11};
12
13/// Which surface a transcript renders for. The format is identical; only the
14/// follow-up vocabulary differs - the MCP tools are `pond_get_session` /
15/// `pond_get_message` / `pond_sql` with `key=value` args, the CLI verbs are
16/// `pond get-session <ID>` / `pond get-message <ID>` / `pond sql`. Without
17/// this a human at the terminal is told to run tool syntax their shell
18/// rejects.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Surface {
21    Mcp,
22    Cli,
23}
24
25/// `1 message` / `2 messages`: a count with a correctly pluralized noun.
26fn count_noun(count: usize, noun: &str) -> String {
27    if count == 1 {
28        format!("{count} {noun}")
29    } else {
30        format!("{count} {noun}s")
31    }
32}
33
34/// Footer for a `pond_get_session` response listing the session's spawn-only
35/// subagents. Each subagent is its own session (spec.md#datasets) addressable
36/// by the printed id, so the caller can open any with `pond_get_session`;
37/// without this they are invisible from the MCP surface.
38pub fn render_subagents_footer(children: &[crate::wire::Session], surface: Surface) -> String {
39    use std::fmt::Write;
40    let how = match surface {
41        Surface::Mcp => "pass an id to pond_get_session(id=...)",
42        Surface::Cli => "pass an id to `pond get-session <ID>`",
43    };
44    let mut out = String::new();
45    let _ = writeln!(out);
46    let _ = writeln!(out, "subagents ({}) - {how}:", children.len());
47    for child in children {
48        let _ = writeln!(out, "  {} | {}", child.id, child.source_agent);
49    }
50    out
51}
52
53/// `YYYY-MM-DD HH:MM:SSZ` - compact, sortable, timezone-explicit.
54fn fmt_ts(ts: &chrono::DateTime<chrono::Utc>) -> String {
55    ts.format("%Y-%m-%d %H:%M:%SZ").to_string()
56}
57
58/// Inner string of an `Extracted<String>` option, or `?` when the source
59/// carried none (spec.md#model-no-synthesis: absence is real, not a blank).
60fn opt_name(value: &Option<crate::adapter::extract::Extracted<String>>) -> &str {
61    value.as_deref().map(String::as_str).unwrap_or("?")
62}
63
64/// Append each line of `body` to `out`, so escaped `\n` in stored text
65/// renders as real line breaks. A trailing blank line in the source is
66/// dropped (lines() already does this).
67fn push_lines(out: &mut String, body: &str, indent: &str) {
68    use std::fmt::Write;
69    for line in body.lines() {
70        let _ = writeln!(out, "{indent}{line}");
71    }
72}
73
74/// Char ceiling for a rendered `pond_search` transcript (spec.md#search).
75/// Enforced as per-session fair-share truncation that always renders every
76/// returned session's top hit - never a whole-response guillotine. The
77/// structured response (HTTP) is unaffected; this bounds only the agent
78/// transcript. Soft: a single session's header + one hit may nudge past it.
79const SEARCH_TRANSCRIPT_BUDGET: usize = 10_000;
80
81pub fn render_search_transcript(
82    response: &SearchResponse,
83    request: &SearchRequest,
84    surface: Surface,
85) -> String {
86    use std::fmt::Write;
87    let prefix = match surface {
88        Surface::Mcp => "pond_search",
89        Surface::Cli => "pond search",
90    };
91    let subagent_note = match (default_excludes_subagents(&request.filters), surface) {
92        (false, _) => "",
93        (true, Surface::Mcp) => {
94            " Subagent sessions excluded; reach them via pond_sql (parent_session_id)."
95        }
96        (true, Surface::Cli) => {
97            " Subagent sessions excluded; reach them via `pond sql` (parent_session_id)."
98        }
99    };
100    let recency_note = if matches!(request.sort_by, SortBy::Recency) {
101        " Sorted by recency (newest first) - rank is NOT match strength."
102    } else {
103        ""
104    };
105    if response.sessions.is_empty() {
106        // spec.md#search-absence-honesty: name the scope size and the
107        // recovery path - a zero-hit response must distinguish "nothing
108        // relevant exists" from "the filters excluded everything" from "the
109        // store simply has nothing stored yet".
110        if response.searchable_in_scope == 0 {
111            let scoped = request.filters.project.is_some()
112                || request.filters.session_id.is_some()
113                || request.filters.from_date.is_some()
114                || request.filters.to_date.is_some();
115            if scoped {
116                return format!(
117                    "{prefix}: 0 searchable messages in scope - the filters exclude \
118                     everything before retrieval. Widen or drop project/date filters.\
119                     {subagent_note}\n"
120                );
121            }
122            // No filters were set: the corpus itself is empty, so pointing
123            // at filters would send the user chasing settings they never made.
124            return match surface {
125                Surface::Cli => format!(
126                    "{prefix}: no sessions stored yet - run `pond init` to set up \
127                     adapters, then `pond sync` to import your history.\n"
128                ),
129                Surface::Mcp => format!(
130                    "{prefix}: the store has no searchable messages yet (nothing \
131                     ingested so far). Not an absence signal about the topic.\n"
132                ),
133            };
134        }
135        let fts_hint = match surface {
136            Surface::Mcp => {
137                " For exact strings or identifiers, try pond_sql: SELECT \
138                 message_id, session_id, search_text FROM messages WHERE \
139                 contains_tokens(search_text, '...')."
140            }
141            Surface::Cli => {
142                " For exact strings or identifiers, try: pond sql \"SELECT \
143                 message_id, session_id, search_text FROM messages WHERE \
144                 contains_tokens(search_text, '...')\"."
145            }
146        };
147        return format!(
148            "{prefix}: no matches for {:?} across {} in \
149             scope.{subagent_note}{fts_hint}\n",
150            request.query,
151            count_noun(response.searchable_in_scope, "searchable message"),
152        );
153    }
154    let shown: usize = response.sessions.iter().map(|s| s.matches.len()).sum();
155    // Vector mode ranks by similarity and ALWAYS returns the nearest rows,
156    // even when none truly match (cosine bands for present vs absent content
157    // overlap, so there is deliberately no score cutoff) - so call them
158    // "nearest", not "matching", or a gibberish query looks like confident
159    // relevance. fts requires real token overlap, so "matching" is honest there.
160    let vector_mode = matches!(request.mode, SearchModeWire::Vector);
161    let head_noun = if vector_mode {
162        "nearest message"
163    } else {
164        "matching message"
165    };
166    let mut out = String::new();
167    let _ = writeln!(
168        out,
169        "{prefix}: {} ({} searchable in scope), showing {} from {}.{}{}",
170        count_noun(response.matched_total, head_noun),
171        response.searchable_in_scope,
172        count_noun(shown, "hit"),
173        count_noun(response.sessions.len(), "session"),
174        subagent_note,
175        recency_note,
176    );
177    let order = if matches!(request.sort_by, SortBy::Recency) {
178        "newest session first"
179    } else {
180        "ordered by best hit"
181    };
182    let full_hint = match surface {
183        Surface::Mcp => "pond_get_message <message_id> for full, pond_get_session for the session",
184        Surface::Cli => "`pond get-message <ID>` for full, `pond get-session <ID>` for the session",
185    };
186    let mode_note = match (vector_mode, surface) {
187        (false, _) => "",
188        (true, Surface::Cli) => {
189            " Vector mode returns the closest rows by meaning even when none are strong; for exact-word matching use --mode fts."
190        }
191        (true, Surface::Mcp) => {
192            " Vector mode returns the closest rows by meaning even when none are strong; for exact-word matching set mode=\"fts\"."
193        }
194    };
195    let _ = writeln!(
196        out,
197        "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. {full_hint}; raise limit for more (no pagination).{mode_note}"
198    );
199    let mut index = 0;
200    let n_sessions = response.sessions.len();
201    for (session_index, session) in response.sessions.iter().enumerate() {
202        // Highest score among the session's matches. Not `matches.first()`:
203        // matches render newest-first, so the first need not be the best.
204        let best = session
205            .matches
206            .iter()
207            .map(|hit| hit.score)
208            .fold(0.0_f64, f64::max);
209        let _ = writeln!(out);
210        let _ = writeln!(
211            out,
212            "{}",
213            rule_line(&format!(
214                "session [{}] best {:.2} | {}/{} matched | {} | {} | {}",
215                session_index + 1,
216                best,
217                session.matched_message_count,
218                session.session_messages_count,
219                session.project,
220                session.source_agent,
221                session.session_id,
222            )),
223        );
224        // Even share of the remaining budget across the sessions still to
225        // render, so all of them surface at least their newest hit (never a
226        // whole-response guillotine). Extra hits in a session stop once its
227        // share is spent; the first hit always renders.
228        let remaining = SEARCH_TRANSCRIPT_BUDGET.saturating_sub(out.len());
229        let share = remaining / (n_sessions - session_index);
230        let session_start = out.len();
231        let mut rendered = 0usize;
232        for hit in &session.matches {
233            if rendered > 0 && out.len().saturating_sub(session_start) >= share {
234                break;
235            }
236            index += 1;
237            let _ = writeln!(out);
238            let _ = writeln!(
239                out,
240                "{}",
241                rule_line(&format!(
242                    "[{index}] {:.2} | {} | {} | {} | {} | {} | {}",
243                    hit.score,
244                    hit.role.as_str(),
245                    fmt_ts(&hit.timestamp),
246                    hit.message_id,
247                    session.project,
248                    session.source_agent,
249                    session.session_id,
250                )),
251            );
252            push_lines(&mut out, &hit.text, "");
253            rendered += 1;
254        }
255        // Intra-session supersession signal (spec.md#search): when the char
256        // budget cut this session's matches short, point the agent at the
257        // session's latest state, which may revise these older hits.
258        let omitted = session.matches.len() - rendered;
259        if omitted > 0 {
260            let latest_hint = match surface {
261                Surface::Mcp => "read with pond_get_session from=end",
262                Surface::Cli => "read with `pond get-session <ID> --from end`",
263            };
264            let _ = writeln!(
265                out,
266                "... {omitted} more match(es) in this session not shown (char budget); \
267                 {latest_hint} for the session's latest state"
268            );
269        }
270    }
271    out
272}
273
274pub fn render_get_transcript(response: &GetResponse, surface: Surface) -> String {
275    use std::fmt::Write;
276    let session = &response.session;
277    let mut out = String::new();
278    match &response.result {
279        GetResult::Session {
280            messages,
281            before_remaining,
282            after_remaining,
283            resolved_from_message_id,
284        } => {
285            let prefix = match surface {
286                Surface::Mcp => "pond_get_session",
287                Surface::Cli => "pond get-session",
288            };
289            // A partial page must announce the session total up front: a
290            // header saying only the page's count reads as the whole session
291            // (field-tested miss - the rest of a long session went unread).
292            let total = *before_remaining + messages.len() + *after_remaining;
293            if total > messages.len() {
294                let start = *before_remaining + 1;
295                let end = *before_remaining + messages.len();
296                let _ = writeln!(
297                    out,
298                    "{prefix}: session {}, messages {start}-{end} of {total} \
299                     (pages are bounded by limit and a size budget).",
300                    session.id,
301                );
302            } else {
303                let _ = writeln!(
304                    out,
305                    "{prefix}: session {}, {}.",
306                    session.id,
307                    count_noun(messages.len(), "message"),
308                );
309            }
310            // The id was a message id; say so before the transcript so the
311            // caller knows the page is anchored, not the session start.
312            if let Some(message_id) = resolved_from_message_id {
313                let expand = match surface {
314                    Surface::Mcp => format!("pond_get_message id={message_id}"),
315                    Surface::Cli => format!("`pond get-message {message_id}`"),
316                };
317                let _ = writeln!(
318                    out,
319                    "note: {message_id} is a message id - resolved to its session; this page \
320                     starts at that message ({expand} for its full part bodies)."
321                );
322            }
323            let (expand_hint, page_hint) = match surface {
324                Surface::Mcp => (
325                    "pond_get_message id=<id> to expand any tool body",
326                    "Page with before_message_id / after_message_id.",
327                ),
328                Surface::Cli => (
329                    "`pond get-message <ID>` to expand any tool body",
330                    "Page with --before-message-id / --after-message-id.",
331                ),
332            };
333            let _ = writeln!(
334                out,
335                "key: \"--- [n] role | time | message_id ---\" delimits each message; \"->\" tool call, \"<-\" result; {expand_hint}. {page_hint}"
336            );
337            // Top marker: earlier messages precede this page (page up).
338            if *before_remaining > 0
339                && let Some(first) = messages.first()
340            {
341                let page_up = match surface {
342                    Surface::Mcp => format!("pass before_message_id={}", first.id),
343                    Surface::Cli => {
344                        format!("pass --before-message-id {}", first.id)
345                    }
346                };
347                let _ = writeln!(
348                    out,
349                    "... {before_remaining} earlier messages; {page_up} to page up",
350                );
351            }
352            for (idx, message) in messages.iter().enumerate() {
353                let _ = writeln!(out);
354                render_message(
355                    &mut out,
356                    idx + 1,
357                    message,
358                    None,
359                    &message.parts_summary,
360                    false,
361                );
362            }
363            let _ = writeln!(out);
364            let _ = writeln!(
365                out,
366                "session {} | {} | {}",
367                session.id, session.source_agent, session.project,
368            );
369            // Bottom marker: later messages follow this page (page down). The
370            // supersession note guards the field-tested failure where an agent
371            // reads an early page of a long session and reports a
372            // since-revised conclusion as current.
373            if *after_remaining > 0
374                && let Some(last) = messages.last()
375            {
376                let page_down = match surface {
377                    Surface::Mcp => format!("pass after_message_id={}", last.id),
378                    Surface::Cli => format!("pass --after-message-id {}", last.id),
379                };
380                let latest = match surface {
381                    Surface::Mcp => "from=\"end\"",
382                    Surface::Cli => "--from end",
383                };
384                let _ = writeln!(
385                    out,
386                    "... {after_remaining} later messages; {page_down} to page down \
387                     (conclusions may have been revised - {latest} reads the latest turns)",
388                );
389            }
390        }
391        GetResult::Message {
392            target,
393            target_parts,
394            target_parts_remaining,
395            siblings,
396            context_before,
397            context_after,
398        } => {
399            let prefix = match surface {
400                Surface::Mcp => "pond_get_message",
401                Surface::Cli => "pond get-message",
402            };
403            let _ = writeln!(
404                out,
405                "{prefix}: thread around {} in session {} (context -{}/+{}).",
406                target.id, session.id, context_before, context_after,
407            );
408            let expand_hint = match surface {
409                Surface::Mcp => "pond_get_message id=<id> to expand any line",
410                Surface::Cli => "`pond get-message <ID>` to expand any line",
411            };
412            let _ = writeln!(
413                out,
414                "key: \"--- [n] role | time | message_id ---\" delimits each message; \">\" = the one you requested; \"->\" tool call, \"<-\" result. {expand_hint}."
415            );
416            // Interleave target with siblings, ordered by (timestamp, id) to
417            // match storage - codex writes many messages at the same
418            // timestamp, so the id is the real tiebreak (a bare timestamp
419            // sort scrambles them). Drop context siblings with nothing to
420            // render (carrier turns with no text/content); the requested
421            // target always stays, even if empty.
422            let mut thread: Vec<(&MessageView, bool)> =
423                siblings.iter().map(|view| (view, false)).collect();
424            thread.push((target, true));
425            thread.sort_by(|a, b| {
426                a.0.timestamp
427                    .cmp(&b.0.timestamp)
428                    .then_with(|| a.0.id.cmp(&b.0.id))
429            });
430            thread.retain(|(view, is_target)| *is_target || message_has_content(view));
431            for (idx, (view, is_target)) in thread.iter().enumerate() {
432                let _ = writeln!(out);
433                // Only the target carries full parts; siblings render as
434                // conversational text + one-line summaries.
435                let parts: Option<&[ResponsePart]> = is_target.then_some(target_parts.as_slice());
436                render_message(
437                    &mut out,
438                    idx + 1,
439                    view,
440                    parts,
441                    &view.parts_summary,
442                    *is_target,
443                );
444            }
445            let _ = writeln!(out);
446            let _ = writeln!(
447                out,
448                "session {} | {} | {}",
449                session.id, session.source_agent, session.project,
450            );
451            if *target_parts_remaining > 0 {
452                let _ = writeln!(
453                    out,
454                    "... {} more parts of {} omitted (response budget)",
455                    target_parts_remaining, target.id,
456                );
457            }
458        }
459    }
460    out
461}
462
463/// Whether a message view has anything to render below its header: real
464/// text/content or a one-line part summary. Used to drop empty carrier
465/// turns from message-scope context.
466fn message_has_content(view: &MessageView) -> bool {
467    view.text.as_deref().is_some_and(|t| !t.trim().is_empty())
468        || view
469            .content
470            .as_deref()
471            .is_some_and(|c| !c.trim().is_empty())
472        || !view.parts_summary.is_empty()
473}
474
475/// Target column width for a delimiter-rule header.
476const RULE_WIDTH: usize = 72;
477
478/// Wrap `inner` as a delimiter rule: `--- {inner} ----...` padded to
479/// [`RULE_WIDTH`] (always at least a 3-dash tail when `inner` is already
480/// wide). Used for both search hits and get message headers.
481fn rule_line(inner: &str) -> String {
482    let head = format!("--- {inner} ");
483    let pad = RULE_WIDTH.saturating_sub(head.chars().count()).max(3);
484    format!("{head}{}", "-".repeat(pad))
485}
486
487/// One message block: an indexed `--- [n] role | time | id ---` delimiter
488/// rule (unambiguous even when the body has blank lines or `##` headings),
489/// then text/content as real lines, then parts - full bodies when `parts`
490/// is present, else one-line summaries.
491fn render_message(
492    out: &mut String,
493    index: usize,
494    view: &MessageView,
495    parts: Option<&[ResponsePart]>,
496    summary: &[PartSummary],
497    is_target: bool,
498) {
499    use std::fmt::Write;
500    let marker = if is_target { "> " } else { "" };
501    let _ = writeln!(
502        out,
503        "{}",
504        rule_line(&format!(
505            "[{index}] {marker}{} | {} | {}",
506            view.role.as_str(),
507            fmt_ts(&view.timestamp),
508            view.id,
509        )),
510    );
511    if let Some(text) = &view.text {
512        push_lines(out, text, "");
513    }
514    if let Some(content) = &view.content {
515        push_lines(out, content, "");
516    }
517    match parts {
518        Some(parts) => {
519            for part in parts {
520                render_part_full(out, part);
521            }
522        }
523        None => {
524            for part in summary {
525                render_part_summary(out, part);
526            }
527        }
528    }
529}
530
531fn render_part_full(out: &mut String, part: &ResponsePart) {
532    use std::fmt::Write;
533    match &part.kind {
534        PartKind::Text { text } => {
535            if let Some(text) = text {
536                push_lines(out, text, "");
537            }
538        }
539        PartKind::Reasoning { text } => {
540            let _ = writeln!(out, "  (reasoning)");
541            if let Some(text) = text {
542                push_lines(out, text, "  ");
543            }
544        }
545        PartKind::ToolCall {
546            name,
547            call_id,
548            params,
549            ..
550        } => {
551            let _ = writeln!(out, "  -> {} [{}]", opt_name(name), opt_name(call_id));
552            push_lines(out, &value_to_text(params), "     ");
553        }
554        PartKind::ToolResult {
555            name,
556            call_id,
557            is_failure,
558            result,
559        } => {
560            let status = if *is_failure { "failed" } else { "ok" };
561            let _ = writeln!(
562                out,
563                "  <- {} [{}] ({status})",
564                opt_name(name),
565                opt_name(call_id),
566            );
567            push_lines(out, &value_to_text(result), "     ");
568        }
569        PartKind::File {
570            media_type,
571            file_name,
572            ..
573        } => {
574            let label = file_name
575                .as_deref()
576                .or(media_type.as_deref())
577                .unwrap_or("file");
578            let _ = writeln!(out, "  [file {label}]");
579        }
580        PartKind::ToolApprovalRequest { approval_id, .. } => {
581            let _ = writeln!(out, "  [approval request {approval_id}]");
582        }
583        PartKind::ToolApprovalResponse {
584            approval_id,
585            approved,
586            ..
587        } => {
588            let verb = if *approved { "approved" } else { "denied" };
589            let _ = writeln!(out, "  [approval {approval_id} {verb}]");
590        }
591    }
592}
593
594fn render_part_summary(out: &mut String, summary: &PartSummary) {
595    use std::fmt::Write;
596    let label = summary.label.as_deref().unwrap_or("");
597    let call = summary
598        .call_id
599        .as_deref()
600        .map(|id| format!(" [{id}]"))
601        .unwrap_or_default();
602    match summary.kind.as_str() {
603        "tool_call" => {
604            let _ = writeln!(out, "  -> {label}{call}");
605        }
606        "tool_result" => {
607            let _ = writeln!(out, "  <- {label}{call}");
608        }
609        "file" => {
610            let _ = writeln!(out, "  [file {label}]");
611        }
612        other => {
613            let _ = writeln!(out, "  [{other} {label}]");
614        }
615    }
616}
617
618/// Render a tool param/result `Value` for the transcript: a JSON string
619/// shows as its text; anything else as compact JSON. `null` shows nothing.
620fn value_to_text(value: &serde_json::Value) -> String {
621    match value {
622        serde_json::Value::String(text) => text.clone(),
623        serde_json::Value::Null => String::new(),
624        other => serde_json::to_string(other).unwrap_or_default(),
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    #![allow(clippy::expect_used, clippy::unwrap_used)]
631
632    use super::*;
633    use crate::wire::{Role, SearchFilters, SearchModeWire, SearchResult};
634
635    #[test]
636    fn get_transcript_marks_target_and_renders_tool_parts() {
637        let ts = chrono::DateTime::from_timestamp(0, 0).unwrap();
638        let tool_call: ResponsePart = serde_json::from_value(serde_json::json!({
639            "id": "p1", "ordinal": 0, "provenance": "conversational",
640            "type": "tool_call", "name": "Bash", "call_id": "toolu_x",
641            "params": { "command": "ls" }, "provider_executed": false,
642        }))
643        .unwrap();
644        let tool_result: ResponsePart = serde_json::from_value(serde_json::json!({
645            "id": "p2", "ordinal": 1, "provenance": "conversational",
646            "type": "tool_result", "name": "Bash", "call_id": "toolu_x",
647            "is_failure": false, "result": "file.txt",
648        }))
649        .unwrap();
650        let target = MessageView {
651            id: "m1".to_owned(),
652            role: crate::wire::Role::Assistant,
653            timestamp: ts,
654            text: Some("Let me list files.".to_owned()),
655            content: None,
656            parts_summary: Vec::new(),
657        };
658        let response = GetResponse {
659            session: crate::wire::GetSession {
660                id: "s1".to_owned(),
661                source_agent: "claude-code".to_owned(),
662                project: "/p".to_owned(),
663                created_at: ts,
664            },
665            result: GetResult::Message {
666                target,
667                target_parts: vec![tool_call, tool_result],
668                target_parts_remaining: 0,
669                siblings: Vec::new(),
670                context_before: 3,
671                context_after: 3,
672            },
673        };
674
675        let transcript = crate::render::render_get_transcript(&response, Surface::Mcp);
676        assert!(transcript.contains("--- [1] > assistant | 1970-01-01 00:00:00Z | m1 ---"));
677        assert!(transcript.contains("Let me list files."));
678        assert!(transcript.contains("  -> Bash [toolu_x]"));
679        assert!(transcript.contains("  <- Bash [toolu_x] (ok)"));
680        assert!(transcript.contains("session s1 | claude-code | /p"));
681    }
682
683    #[test]
684    fn session_header_states_span_and_total_on_partial_pages() {
685        let ts = chrono::DateTime::from_timestamp(0, 0).unwrap();
686        let message = |id: &str| MessageView {
687            id: id.to_owned(),
688            role: crate::wire::Role::User,
689            timestamp: ts,
690            text: Some("hi".to_owned()),
691            content: None,
692            parts_summary: Vec::new(),
693        };
694        let session = crate::wire::GetSession {
695            id: "s1".to_owned(),
696            source_agent: "claude-code".to_owned(),
697            project: "/p".to_owned(),
698            created_at: ts,
699        };
700        let partial = GetResponse {
701            session: session.clone(),
702            result: GetResult::Session {
703                messages: vec![message("m1"), message("m2")],
704                before_remaining: 100,
705                after_remaining: 32,
706                resolved_from_message_id: None,
707            },
708        };
709        let transcript = crate::render::render_get_transcript(&partial, Surface::Mcp);
710        assert!(transcript.starts_with(
711            "pond_get_session: session s1, messages 101-102 of 134 \
712             (pages are bounded by limit and a size budget)."
713        ));
714
715        let full = GetResponse {
716            session,
717            result: GetResult::Session {
718                messages: vec![message("m1"), message("m2")],
719                before_remaining: 0,
720                after_remaining: 0,
721                resolved_from_message_id: None,
722            },
723        };
724        let transcript = crate::render::render_get_transcript(&full, Surface::Cli);
725        assert!(transcript.starts_with("pond get-session: session s1, 2 messages."));
726    }
727
728    #[test]
729    fn search_transcript_renders_header_and_hits() {
730        let response = SearchResponse {
731            sessions: vec![crate::wire::SearchSession {
732                session_id: "s1".to_owned(),
733                project: "pond".to_owned(),
734                source_agent: "claude-code".to_owned(),
735                session_messages_count: 2,
736                matched_message_count: 1,
737                matches: vec![SearchResult {
738                    message_id: "m1".to_owned(),
739                    role: Role::User,
740                    timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(),
741                    text: "hello\nworld".to_owned(),
742                    score: 1.0,
743                    parts_summary: Vec::new(),
744                }],
745            }],
746            matched_total: 1,
747            searchable_in_scope: 2,
748            has_more: false,
749        };
750        let request = SearchRequest {
751            protocol_version: crate::PROTOCOL_VERSION,
752            namespace: None,
753            query: "hi".to_owned(),
754            mode: SearchModeWire::Vector,
755            sort_by: SortBy::Relevance,
756            filters: SearchFilters::default(),
757            limit: 10,
758        };
759
760        let transcript = crate::render::render_search_transcript(&response, &request, Surface::Mcp);
761        assert!(transcript.starts_with(
762            "pond_search: 1 nearest message (2 searchable in scope), showing 1 hit from 1 \
763             session."
764        ));
765        // Vector mode names the closest-rows caveat and points at fts.
766        assert!(transcript.contains("Vector mode returns the closest rows"));
767        assert!(
768            transcript.contains("key: session rules group hits by session, ordered by best hit")
769        );
770        assert!(
771            transcript
772                .contains("--- session [1] best 1.00 | 1/2 matched | pond | claude-code | s1")
773        );
774        // Hit lines stay flat and indexed so callers can still extract
775        // message_id from the same delimiter shape.
776        assert!(
777            transcript.contains(
778                "--- [1] 1.00 | user | 1970-01-01 00:00:00Z | m1 | pond | claude-code | s1"
779            )
780        );
781        // Stored "\n" renders as a real line break, not an escape.
782        assert!(transcript.contains("hello\nworld"));
783    }
784
785    #[test]
786    fn search_transcript_budget_keeps_every_session_and_footers_the_truncated_one() {
787        let big = "x".repeat(600);
788        let hit = |id: usize| SearchResult {
789            message_id: format!("m{id}"),
790            role: Role::Assistant,
791            timestamp: chrono::DateTime::from_timestamp(id as i64, 0).unwrap(),
792            text: big.clone(),
793            score: 0.9,
794            parts_summary: Vec::new(),
795        };
796        let session = |id: &str, matches: Vec<SearchResult>| crate::wire::SearchSession {
797            session_id: id.to_owned(),
798            project: "pond".to_owned(),
799            source_agent: "claude-code".to_owned(),
800            session_messages_count: 100,
801            matched_message_count: matches.len(),
802            matches,
803        };
804        // One fat session whose matches alone exceed the budget, plus five
805        // more that must each still surface their top hit.
806        let mut sessions = vec![session("fat", (0..40).map(hit).collect())];
807        for s in 1..=5 {
808            sessions.push(session(&format!("s{s}"), vec![hit(s * 1000)]));
809        }
810        let response = SearchResponse {
811            sessions,
812            matched_total: 45,
813            searchable_in_scope: 200,
814            has_more: false,
815        };
816        let request = SearchRequest {
817            protocol_version: crate::PROTOCOL_VERSION,
818            namespace: None,
819            query: "x".to_owned(),
820            mode: SearchModeWire::Vector,
821            sort_by: SortBy::Relevance,
822            filters: SearchFilters::default(),
823            limit: 10,
824        };
825        let transcript = crate::render::render_search_transcript(&response, &request, Surface::Mcp);
826
827        // Bounded near the budget (soft: each session's guaranteed top hit
828        // can nudge its share, so allow a per-session overshoot margin).
829        assert!(
830            transcript.len() < SEARCH_TRANSCRIPT_BUDGET + 3_000,
831            "transcript {} exceeds the soft budget",
832            transcript.len(),
833        );
834        // Never a whole-response guillotine: every returned session renders.
835        for id in ["fat", "s1", "s2", "s3", "s4", "s5"] {
836            assert!(
837                transcript.contains(&format!("| {id}\n"))
838                    || transcript.contains(&format!("| {id} ")),
839                "session {id} did not render",
840            );
841        }
842        // The fat session was cut short -> supersession footer pointing at
843        // the session's latest state.
844        assert!(transcript.contains("more match(es) in this session not shown (char budget)"));
845        assert!(transcript.contains("pond_get_session from=end"));
846    }
847}