Skip to main content

grokbot_api/
transcript.rs

1//! Transcript entries (`getAgentTranscriptTail` / `getAgentThread`), parsed like
2//! grok-bot-cli's `transcript.js` / `format.js` / `grok-approvals.js`.
3use serde_json::Value;
4use std::collections::BTreeMap;
5
6/// A page of transcript entries (`getAgentTranscriptTail`), a walked history
7/// ([`Client::transcript_history`](crate::Client::transcript_history)) or a thread (`getAgentThread`).
8#[derive(Debug, Clone, PartialEq, Default)]
9pub struct Transcript {
10    /// Entries, oldest first.
11    pub entries: Vec<Entry>,
12    /// Present when more older entries exist; pass as `before_seq` on the next page.
13    pub next_before_seq: Option<u64>,
14    /// The raw response (for a walked history: `{"pages": [...]}`).
15    pub raw: Value,
16}
17
18impl Transcript {
19    pub(crate) fn from_value(v: Value) -> Self {
20        let entries = v
21            .get("entries")
22            .and_then(Value::as_array)
23            .map(|a| a.iter().map(Entry::from_value).collect())
24            .unwrap_or_default();
25        let next_before_seq = v
26            .get("nextBeforeSeq")
27            .and_then(|x| x.as_u64().or_else(|| x.as_i64().map(|n| n as u64)));
28        Transcript {
29            entries,
30            next_before_seq,
31            raw: v,
32        }
33    }
34}
35
36/// One transcript entry: a user prompt, a bot reply, a tool event, an approval card, ...
37///
38/// The gateway's entry vocabulary is undocumented; [`Entry::raw`] keeps the full record.
39#[derive(Debug, Clone, PartialEq, Default)]
40pub struct Entry {
41    /// Source entry id (may be missing).
42    pub id: Option<String>,
43    /// First of `role`, `kind`, `sender`, `type`; `"msg"` if none.
44    pub role: String,
45    /// Best text body (see `entryText` in grok-bot-cli). Not sanitized.
46    pub text: String,
47    /// The raw entry.
48    pub raw: Value,
49}
50
51fn entry_text(e: &Value) -> String {
52    if !e.is_object() {
53        return String::new();
54    }
55    for k in ["text", "prompt", "message"] {
56        if let Some(s) = e.get(k).and_then(Value::as_str).filter(|s| !s.is_empty()) {
57            return s.to_string();
58        }
59    }
60    match e.get("content") {
61        Some(Value::String(s)) => return s.clone(),
62        Some(Value::Array(parts)) => {
63            return parts
64                .iter()
65                .map(|p| match p {
66                    Value::String(s) => s.clone(),
67                    Value::Object(_) => p
68                        .get("text")
69                        .and_then(Value::as_str)
70                        .or_else(|| p.get("content").and_then(Value::as_str))
71                        .unwrap_or("")
72                        .to_string(),
73                    _ => String::new(),
74                })
75                .filter(|s| !s.is_empty())
76                .collect::<Vec<_>>()
77                .join("\n");
78        }
79        Some(c @ Value::Object(_)) => {
80            return c
81                .get("text")
82                .and_then(Value::as_str)
83                .map(str::to_string)
84                .unwrap_or_else(|| c.to_string()); // compact JSON
85        }
86        _ => {}
87    }
88    // Bot replies: `{ kind: "send-message", message: { type, content } }`.
89    if let Some(s) = e
90        .get("message")
91        .and_then(|m| m.get("content"))
92        .and_then(Value::as_str)
93    {
94        return s.to_string();
95    }
96    e.get("preview")
97        .and_then(Value::as_str)
98        .unwrap_or("")
99        .to_string()
100}
101
102impl Entry {
103    /// Parse one raw entry. Never fails; unknown shapes give an empty `text`.
104    ///
105    /// ```
106    /// use grokbot_api::Entry;
107    /// use serde_json::json;
108    ///
109    /// let e = Entry::from_value(&json!({"id": "e2", "kind": "send-message",
110    ///     "message": {"type": "text", "content": "hello"}}));
111    /// assert_eq!((e.role.as_str(), e.text.as_str()), ("send-message", "hello"));
112    /// assert!(!e.is_from_user());
113    /// ```
114    pub fn from_value(v: &Value) -> Entry {
115        let role = ["role", "kind", "sender", "type"]
116            .iter()
117            .find_map(|k| v.get(*k).and_then(Value::as_str).filter(|s| !s.is_empty()))
118            .unwrap_or("msg")
119            .to_string();
120        Entry {
121            id: v.get("id").and_then(Value::as_str).map(str::to_string),
122            role,
123            text: entry_text(v),
124            raw: v.clone(),
125        }
126    }
127
128    /// Heuristic: the role names a user/human/prompt entry. The gateway's role vocabulary is
129    /// undocumented; bot replies are known to use `kind: "send-message"`.
130    pub fn is_from_user(&self) -> bool {
131        let r = self.role.to_lowercase();
132        r.contains("user") || r.contains("human") || r.contains("prompt")
133    }
134
135    /// A pending Grok approval card carried by this entry, if any.
136    pub fn approval(&self) -> Option<Approval> {
137        Approval::from_entry(&self.raw)
138    }
139}
140
141/// Which resolve method an [`Approval`] needs.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum ApprovalKind {
144    /// `message.type == "auto-review-approval"` → resolve with `resolveAutoReviewApproval`.
145    AutoReview,
146    /// `message.type == "local-tool-permission"` → resolve with `resolveLocalToolPermission`.
147    LocalToolPermission,
148}
149
150/// A pending approval request found in a transcript (see grok-bot-cli `grokApproval`).
151/// Never approve automatically: approvals need an explicit user decision.
152#[derive(Debug, Clone, PartialEq)]
153pub struct Approval {
154    /// The transcript entry carrying the card (`entryId` when resolving).
155    pub entry_id: String,
156    /// The card's `requestId`.
157    pub request_id: String,
158    /// Which resolve method to call.
159    pub kind: ApprovalKind,
160    /// reason, command, summary, action, description, target, machineId, surface, workingDirectory (≤2048 chars each).
161    pub details: BTreeMap<String, String>,
162    /// Some detail was longer than 2048 characters and was cut.
163    pub truncated: bool,
164}
165
166impl Approval {
167    fn from_entry(e: &Value) -> Option<Approval> {
168        if e.get("kind").and_then(Value::as_str) != Some("send-message") {
169            return None;
170        }
171        let msg = e.get("message")?;
172        let (kind, card) = match msg.get("type").and_then(Value::as_str)? {
173            "auto-review-approval" => (ApprovalKind::AutoReview, msg.get("approval")?),
174            "local-tool-permission" => (ApprovalKind::LocalToolPermission, msg.get("ask")?),
175            _ => return None,
176        };
177        if card.get("status").and_then(Value::as_str) != Some("pending") {
178            return None;
179        }
180        let ok = |s: &str| !s.is_empty() && s.len() <= 1024;
181        let request_id = card
182            .get("requestId")
183            .and_then(Value::as_str)
184            .filter(|s| ok(s))?
185            .to_string();
186        let entry_id = e
187            .get("id")
188            .and_then(Value::as_str)
189            .filter(|s| ok(s))?
190            .to_string();
191        let mut details = BTreeMap::new();
192        let mut truncated = false;
193        for k in [
194            "reason",
195            "command",
196            "summary",
197            "action",
198            "description",
199            "target",
200            "machineId",
201            "surface",
202            "workingDirectory",
203        ] {
204            if let Some(t) = card.get(k).and_then(Value::as_str) {
205                truncated |= t.chars().count() > 2048;
206                details.insert(k.to_string(), t.chars().take(2048).collect());
207            }
208        }
209        Some(Approval {
210            entry_id,
211            request_id,
212            kind,
213            details,
214            truncated,
215        })
216    }
217}
218
219/// Strip ANSI escape sequences and control characters (except `\n` and `\t`) so
220/// server text cannot drive the terminal.
221///
222/// ```
223/// assert_eq!(grokbot_api::sanitize_text("\u{1b}[31mred\u{1b}[0m\u{7}!"), "red!");
224/// ```
225pub fn sanitize_text(s: &str) -> String {
226    let mut out = String::with_capacity(s.len());
227    let mut it = s.chars().peekable();
228    while let Some(c) = it.next() {
229        if c == '\u{1b}' {
230            match it.next() {
231                Some('[') => {
232                    // CSI: parameters/intermediates then a final byte @..~
233                    for d in it.by_ref() {
234                        if ('@'..='~').contains(&d) {
235                            break;
236                        }
237                    }
238                }
239                Some(']') => {
240                    // OSC: until BEL or ESC \
241                    while let Some(d) = it.next() {
242                        if d == '\u{7}' {
243                            break;
244                        }
245                        if d == '\u{1b}' {
246                            it.next();
247                            break;
248                        }
249                    }
250                }
251                _ => {}
252            }
253            continue;
254        }
255        if c == '\n' || c == '\t' || !(c.is_control()) {
256            out.push(c);
257        }
258    }
259    out
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use serde_json::json;
266
267    #[test]
268    fn entry_text_variants() {
269        let t = |v: Value| Entry::from_value(&v).text;
270        assert_eq!(t(json!({"text": "a", "preview": "p"})), "a");
271        assert_eq!(t(json!({"prompt": "b"})), "b");
272        assert_eq!(
273            t(json!({"content": [ "x", {"text": "y"}, {"content": "z"}, 3 ]})),
274            "x\ny\nz"
275        );
276        assert_eq!(
277            t(json!({"kind": "send-message", "message": {"type": "text", "content": "reply"}})),
278            "reply"
279        );
280        assert_eq!(t(json!({"preview": "only preview"})), "only preview");
281        let e = Entry::from_value(&json!({"id": "e1", "kind": "user-prompt", "prompt": "hi"}));
282        assert_eq!(e.id.as_deref(), Some("e1"));
283        assert!(e.is_from_user());
284        assert!(!Entry::from_value(&json!({"kind": "send-message"})).is_from_user());
285        assert_eq!(Entry::from_value(&json!({})).role, "msg");
286    }
287
288    #[test]
289    fn approvals() {
290        let e = Entry::from_value(&json!({
291            "id": "e9", "kind": "send-message",
292            "message": {"type": "local-tool-permission", "ask": {"status": "pending", "requestId": "r1", "command": "rm -rf /"}}
293        }));
294        let a = e.approval().unwrap();
295        assert_eq!((a.entry_id.as_str(), a.request_id.as_str()), ("e9", "r1"));
296        assert_eq!(a.kind, ApprovalKind::LocalToolPermission);
297        assert_eq!(a.details["command"], "rm -rf /");
298        let done = Entry::from_value(&json!({"id": "e", "kind": "send-message",
299            "message": {"type": "auto-review-approval", "approval": {"status": "approved", "requestId": "r"}}}));
300        assert!(done.approval().is_none());
301    }
302
303    #[test]
304    fn sanitize() {
305        assert_eq!(
306            sanitize_text("a\u{1b}[31mred\u{1b}[0m\u{1b}]0;title\u{7}b\u{0}\n\tc"),
307            "aredb\n\tc"
308        );
309    }
310}