Skip to main content

monoloop_interpreter/
acp.rs

1//! ACP / Grok Build dialect mapping (JSON-RPC messages → semantic fragments).
2//!
3//! Maps session/update and related notifications into text fragments and tool
4//! lifecycle signals. Does not execute tools or interpret product UI.
5
6use monoloop_contracts::{TextChannel, ToolActionId};
7use serde_json::Value;
8
9/// Semantic fragments produced by ACP framing (still may be incomplete text).
10#[derive(Clone, Debug)]
11pub enum AcpFragment {
12    /// Text delta to assemble into sentences (channel-tagged).
13    TextDelta {
14        /// Channel.
15        channel: TextChannel,
16        /// Fragment text (not a canonical unit).
17        text: String,
18        /// Dialect `agentTimestampMs` when present on the message (observational).
19        source_time_ms: Option<u64>,
20        /// Dialect stream step (e.g. `_meta.stepIdx` / numeric `messageId`).
21        source_step: Option<u64>,
22    },
23    /// Tool action declared / updated.
24    Tool {
25        /// Action id.
26        action_id: ToolActionId,
27        /// Kind of tool signal.
28        signal: ToolSignal,
29        /// Dialect `agentTimestampMs` when present on the message (observational).
30        source_time_ms: Option<u64>,
31        /// Dialect stream step (e.g. `_meta.stepIdx`).
32        source_step: Option<u64>,
33    },
34    /// Dialect-level response finished.
35    ResponseFinished,
36    /// Safe diagnostic.
37    Diagnostic {
38        /// Message.
39        message: String,
40    },
41}
42
43/// Tool lifecycle signals from ACP (no partial arg escape as ready).
44#[derive(Clone, Debug)]
45pub enum ToolSignal {
46    /// Waiting for complete request (identity known).
47    Waiting {
48        /// Optional tool name if already known without full args.
49        tool_name: Option<String>,
50        /// Why waiting.
51        waiting_for: String,
52    },
53    /// Complete request ready (full name + JSON args).
54    RequestReady {
55        /// Tool name.
56        tool_name: String,
57        /// Complete args as JSON string.
58        arguments_json: String,
59    },
60    /// Observed terminal result from dialect.
61    Resolved {
62        /// Outcome label.
63        success: bool,
64        /// Complete result JSON if present.
65        result_json: Option<String>,
66    },
67}
68
69/// ACP dialect helpers.
70pub struct AcpDialect;
71
72impl AcpDialect {
73    /// Map one complete JSON-RPC message value into fragments.
74    pub fn map_message(value: &Value) -> Vec<AcpFragment> {
75        let mut out = Vec::new();
76        let method = value.get("method").and_then(|m| m.as_str()).unwrap_or("");
77        match method {
78            "session/update" => {
79                if let Some(params) = value.get("params") {
80                    out.extend(map_session_update(params));
81                }
82            }
83            // Cursor extension notifications — observational only (no effects).
84            "cursor/update_todos" | "cursor/task" | "cursor/generate_image" => {
85                out.push(AcpFragment::Diagnostic {
86                    message: format!("cursor extension notification: {method}"),
87                });
88            }
89            // Terminal prompt response often has result.stopReason
90            _ => {
91                if value.get("result").is_some() && value.get("id").is_some() {
92                    if let Some(sr) = value.pointer("/result/stopReason").and_then(|v| v.as_str()) {
93                        if sr == "end_turn" || sr == "max_tokens" || sr == "cancelled" {
94                            out.push(AcpFragment::ResponseFinished);
95                        }
96                    }
97                }
98            }
99        }
100        out
101    }
102}
103
104fn map_session_update(params: &Value) -> Vec<AcpFragment> {
105    let mut out = Vec::new();
106    let source_time_ms = extract_agent_timestamp_ms(params);
107    let update = params.get("update").unwrap_or(params);
108    // Prefer update._meta.stepIdx (Antigravity), then params._meta, then numeric messageId.
109    let source_step = extract_source_step(params, update);
110    let kind = update
111        .get("sessionUpdate")
112        .or_else(|| update.get("type"))
113        .and_then(|v| v.as_str())
114        .unwrap_or("");
115
116    match kind {
117        "agent_message_chunk" | "agent_message" | "message" => {
118            if let Some(text) = extract_text_content(update) {
119                if !text.is_empty() {
120                    out.push(AcpFragment::TextDelta {
121                        channel: TextChannel::PublicResponse,
122                        text,
123                        source_time_ms,
124                        source_step,
125                    });
126                }
127            }
128        }
129        "agent_thought_chunk" | "agent_thought" => {
130            // Only publish as reasoning summary if field is explicitly public summary.
131            // Private CoT is not mapped to PublicResponse.
132            if update
133                .get("public")
134                .and_then(|v| v.as_bool())
135                .unwrap_or(false)
136                || update.get("summary").is_some()
137            {
138                if let Some(text) = extract_text_content(update) {
139                    if !text.is_empty() {
140                        out.push(AcpFragment::TextDelta {
141                            channel: TextChannel::PublicReasoningSummary,
142                            text,
143                            source_time_ms,
144                            source_step,
145                        });
146                    }
147                }
148            }
149            // else: suppress private thought
150        }
151        "tool_call" | "tool_call_update" => {
152            out.extend(map_tool_call(
153                update,
154                kind == "tool_call_update",
155                source_time_ms,
156                source_step,
157            ));
158        }
159        // Known non-content / lifecycle updates — observe silently (no diagnostic noise).
160        // Cursor ACP shares the same core sessionUpdate vocabulary as standard ACP.
161        "available_commands_update"
162        | "current_mode_update"
163        | "plan"
164        | "user_message_chunk"
165        | "session_info_update"
166        | "config_option_update"
167        | "available_commands" => {}
168        other if !other.is_empty() => {
169            out.push(AcpFragment::Diagnostic {
170                message: format!("unsupported sessionUpdate: {other}"),
171            });
172        }
173        _ => {
174            // Try generic content
175            if let Some(text) = extract_text_content(update) {
176                if !text.is_empty() {
177                    out.push(AcpFragment::TextDelta {
178                        channel: TextChannel::PublicResponse,
179                        text,
180                        source_time_ms,
181                        source_step,
182                    });
183                }
184            }
185        }
186    }
187    out
188}
189
190/// Grok ACP places observational time on `params._meta.agentTimestampMs`.
191/// Also accept the same key on the update object when present.
192fn extract_agent_timestamp_ms(params: &Value) -> Option<u64> {
193    let from_meta = |v: &Value| -> Option<u64> {
194        v.get("_meta")
195            .and_then(|m| m.get("agentTimestampMs"))
196            .and_then(|t| t.as_u64().or_else(|| t.as_i64().map(|i| i as u64)))
197    };
198    from_meta(params)
199        .or_else(|| params.get("update").and_then(from_meta))
200        .or_else(|| {
201            // Bare update object passed as params in some fixtures.
202            params
203                .get("agentTimestampMs")
204                .and_then(|t| t.as_u64().or_else(|| t.as_i64().map(|i| i as u64)))
205        })
206}
207
208/// Dialect stream step for human ordering when wall-clock times are absent.
209///
210/// Preference:
211/// 1. `update._meta.stepIdx` (Antigravity)
212/// 2. `params._meta.stepIdx`
213/// 3. numeric `update.messageId` (Antigravity text chunks)
214fn extract_source_step(params: &Value, update: &Value) -> Option<u64> {
215    let step_from = |v: &Value| -> Option<u64> {
216        v.get("_meta").and_then(|m| {
217            m.get("stepIdx")
218                .or_else(|| m.get("step_idx"))
219                .and_then(|t| t.as_u64().or_else(|| t.as_i64().map(|i| i as u64)))
220        })
221    };
222    step_from(update).or_else(|| step_from(params)).or_else(|| {
223        update
224            .get("messageId")
225            .and_then(|m| m.as_u64().or_else(|| m.as_i64().map(|i| i as u64)))
226            .or_else(|| {
227                update
228                    .get("messageId")
229                    .and_then(|m| m.as_str())
230                    .and_then(|s| s.parse().ok())
231            })
232    })
233}
234
235fn extract_text_content(update: &Value) -> Option<String> {
236    if let Some(s) = update.get("text").and_then(|v| v.as_str()) {
237        return Some(s.to_string());
238    }
239    if let Some(content) = update.get("content") {
240        if let Some(s) = content.as_str() {
241            return Some(s.to_string());
242        }
243        if let Some(s) = content.get("text").and_then(|v| v.as_str()) {
244            return Some(s.to_string());
245        }
246        if let Some(arr) = content.as_array() {
247            let mut acc = String::new();
248            for item in arr {
249                if item.get("type").and_then(|t| t.as_str()) == Some("text") {
250                    if let Some(t) = item.get("text").and_then(|v| v.as_str()) {
251                        acc.push_str(t);
252                    }
253                } else if let Some(t) = item.as_str() {
254                    acc.push_str(t);
255                }
256            }
257            if !acc.is_empty() {
258                return Some(acc);
259            }
260        }
261    }
262    None
263}
264
265fn map_tool_call(
266    update: &Value,
267    is_update: bool,
268    source_time_ms: Option<u64>,
269    source_step: Option<u64>,
270) -> Vec<AcpFragment> {
271    let mut out = Vec::new();
272    let id = update
273        .get("toolCallId")
274        .or_else(|| update.get("tool_call_id"))
275        .or_else(|| update.get("id"))
276        .and_then(|v| v.as_str())
277        .unwrap_or("");
278    if id.is_empty() {
279        out.push(AcpFragment::Diagnostic {
280            message: "tool_call missing toolCallId".into(),
281        });
282        return out;
283    }
284    let action_id = ToolActionId::new(id);
285    let name = update
286        .get("title")
287        .or_else(|| update.get("name"))
288        .or_else(|| update.get("toolName"))
289        .and_then(|v| v.as_str())
290        .map(|s| s.to_string());
291
292    // Grok Build often omits `status` (null). Prefer explicit status when present;
293    // otherwise infer terminality from result payload on tool_call_update.
294    let status = update
295        .get("status")
296        .and_then(|v| v.as_str())
297        .unwrap_or("")
298        .to_ascii_lowercase();
299
300    let args = update
301        .get("rawInput")
302        .or_else(|| update.get("arguments"))
303        .or_else(|| update.get("input"));
304    let args_complete = args.is_some()
305        && args
306            .map(|a| a.is_object() || a.is_array() || a.is_string())
307            .unwrap_or(false);
308
309    let result_value = update
310        .get("rawOutput")
311        .or_else(|| update.get("result"))
312        // Grok Build: tool_call_update carries tool outcome as `content`
313        // (e.g. diff array for write, text blocks for read) without status.
314        .or_else(|| {
315            if is_update {
316                update.get("content")
317            } else {
318                None
319            }
320        });
321    let has_result = result_value.is_some_and(is_tool_result_payload);
322
323    let explicit_terminal = matches!(
324        status.as_str(),
325        "completed"
326            | "complete"
327            | "success"
328            | "failed"
329            | "failure"
330            | "error"
331            | "cancelled"
332            | "canceled"
333    );
334    let explicit_failure = matches!(
335        status.as_str(),
336        "failed" | "failure" | "error" | "cancelled" | "canceled"
337    );
338
339    // Terminal: explicit status, or Grok-style update with result content and no
340    // in-progress marker.
341    let in_progress = matches!(
342        status.as_str(),
343        "pending" | "in_progress" | "in-progress" | "running" | "started"
344    );
345    if explicit_terminal || (has_result && !in_progress) {
346        // Emit Ready first when this same message carries complete args, so
347        // hosts still see a complete request before the terminal outcome.
348        if args_complete {
349            if let Some(a) = args {
350                let tool_name = name.clone().unwrap_or_else(|| "unknown".into());
351                let arguments_json = if a.is_string() {
352                    a.as_str().unwrap_or("{}").to_string()
353                } else {
354                    a.to_string()
355                };
356                out.push(AcpFragment::Tool {
357                    action_id: action_id.clone(),
358                    signal: ToolSignal::RequestReady {
359                        tool_name,
360                        arguments_json,
361                    },
362                    source_time_ms,
363                    source_step,
364                });
365            }
366        }
367        let success = !explicit_failure;
368        let result_json = result_value.map(|v| v.to_string());
369        out.push(AcpFragment::Tool {
370            action_id,
371            signal: ToolSignal::Resolved {
372                success,
373                result_json,
374            },
375            source_time_ms,
376            source_step,
377        });
378        return out;
379    }
380
381    if args_complete {
382        if let Some(a) = args {
383            let tool_name = name.unwrap_or_else(|| "unknown".into());
384            let arguments_json = if a.is_string() {
385                a.as_str().unwrap_or("{}").to_string()
386            } else {
387                a.to_string()
388            };
389            out.push(AcpFragment::Tool {
390                action_id,
391                signal: ToolSignal::RequestReady {
392                    tool_name,
393                    arguments_json,
394                },
395                source_time_ms,
396                source_step,
397            });
398            return out;
399        }
400    }
401
402    // Waiting
403    out.push(AcpFragment::Tool {
404        action_id,
405        signal: ToolSignal::Waiting {
406            tool_name: name,
407            waiting_for: if is_update {
408                "tool_call_update incomplete".into()
409            } else {
410                "complete tool request".into()
411            },
412        },
413        source_time_ms,
414        source_step,
415    });
416    out
417}
418
419/// True when `content`/`rawOutput` looks like tool outcome material (not empty).
420fn is_tool_result_payload(v: &Value) -> bool {
421    match v {
422        Value::Null => false,
423        Value::Bool(_) | Value::Number(_) => true,
424        Value::String(s) => !s.is_empty(),
425        Value::Array(a) => !a.is_empty(),
426        Value::Object(m) => !m.is_empty(),
427    }
428}
429
430/// Extract complete JSON values from a growing byte buffer (fragment-safe).
431///
432/// Supports concatenated JSON objects (WebSocket message bodies and partial chunks).
433pub fn drain_json_values(buffer: &mut Vec<u8>) -> Result<Vec<Value>, String> {
434    let mut out = Vec::new();
435    loop {
436        // skip leading whitespace
437        let start = buffer
438            .iter()
439            .position(|b| !b.is_ascii_whitespace())
440            .unwrap_or(buffer.len());
441        if start > 0 {
442            buffer.drain(..start);
443        }
444        if buffer.is_empty() {
445            break;
446        }
447        match find_complete_json_end(buffer) {
448            Some(end) => {
449                let slice = &buffer[..end];
450                let value: Value =
451                    serde_json::from_slice(slice).map_err(|e| format!("json parse: {e}"))?;
452                out.push(value);
453                buffer.drain(..end);
454            }
455            None => break,
456        }
457    }
458    Ok(out)
459}
460
461fn find_complete_json_end(buf: &[u8]) -> Option<usize> {
462    if buf.is_empty() {
463        return None;
464    }
465    let first = buf[0];
466    if first != b'{' && first != b'[' {
467        // not a JSON value start — try line-based
468        if let Some(pos) = buf.iter().position(|&b| b == b'\n') {
469            return Some(pos + 1);
470        }
471        return None;
472    }
473    let mut depth = 0i32;
474    let mut in_string = false;
475    let mut escape = false;
476    for (i, &b) in buf.iter().enumerate() {
477        if in_string {
478            if escape {
479                escape = false;
480                continue;
481            }
482            match b {
483                b'\\' => escape = true,
484                b'"' => in_string = false,
485                _ => {}
486            }
487            continue;
488        }
489        match b {
490            b'"' => in_string = true,
491            b'{' | b'[' => depth += 1,
492            b'}' | b']' => {
493                depth -= 1;
494                if depth == 0 {
495                    return Some(i + 1);
496                }
497            }
498            _ => {}
499        }
500    }
501    None
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn fragment_json_reassembly() {
510        let full = serde_json::json!({
511            "method": "session/update",
512            "params": {
513                "update": {
514                    "sessionUpdate": "agent_message_chunk",
515                    "content": { "type": "text", "text": "Hi. " }
516                }
517            }
518        });
519        let raw = serde_json::to_vec(&full).unwrap();
520        let mid = raw.len() / 2;
521        let mut buf = raw[..mid].to_vec();
522        assert!(drain_json_values(&mut buf).unwrap().is_empty());
523        buf.extend_from_slice(&raw[mid..]);
524        let vals = drain_json_values(&mut buf).unwrap();
525        assert_eq!(
526            vals.len(),
527            1,
528            "buf leftover={}",
529            String::from_utf8_lossy(&buf)
530        );
531        let frags = AcpDialect::map_message(&vals[0]);
532        assert!(matches!(
533            &frags[0],
534            AcpFragment::TextDelta {
535                text,
536                source_time_ms: None,
537                ..
538            } if text == "Hi. "
539        ));
540    }
541
542    #[test]
543    fn extracts_agent_timestamp_ms_from_params_meta() {
544        let msg = serde_json::json!({
545            "jsonrpc": "2.0",
546            "method": "session/update",
547            "params": {
548                "_meta": {
549                    "agentTimestampMs": 1786859347289_u64,
550                    "chunkId": 1
551                },
552                "sessionId": "s",
553                "update": {
554                    "sessionUpdate": "agent_message_chunk",
555                    "content": { "type": "text", "text": "I'll" }
556                }
557            }
558        });
559        let frags = AcpDialect::map_message(&msg);
560        assert!(matches!(
561            &frags[0],
562            AcpFragment::TextDelta {
563                text,
564                source_time_ms: Some(1786859347289),
565                ..
566            } if text == "I'll"
567        ));
568    }
569
570    #[test]
571    fn tool_call_carries_source_time() {
572        let msg = serde_json::json!({
573            "jsonrpc": "2.0",
574            "method": "session/update",
575            "params": {
576                "_meta": { "agentTimestampMs": 1001_u64 },
577                "update": {
578                    "sessionUpdate": "tool_call",
579                    "toolCallId": "call-1",
580                    "title": "write",
581                    "rawInput": { "path": "/tmp/x" }
582                }
583            }
584        });
585        let frags = AcpDialect::map_message(&msg);
586        assert!(
587            frags.iter().any(|f| matches!(
588                f,
589                AcpFragment::Tool {
590                    source_time_ms: Some(1001),
591                    signal: ToolSignal::RequestReady { .. },
592                    ..
593                }
594            )),
595            "{frags:?}"
596        );
597    }
598
599    /// Antigravity: tools carry `update._meta.stepIdx`; text uses numeric `messageId`.
600    #[test]
601    fn extracts_agy_step_idx_and_message_id() {
602        let tool = serde_json::json!({
603            "jsonrpc": "2.0",
604            "method": "session/update",
605            "params": {
606                "sessionId": "s",
607                "update": {
608                    "sessionUpdate": "tool_call",
609                    "toolCallId": "call_1",
610                    "title": "Create file",
611                    "status": "completed",
612                    "rawInput": { "path": "/tmp/x" },
613                    "content": [{ "type": "diff", "path": "/tmp/x" }],
614                    "_meta": { "stepIdx": 3 }
615                }
616            }
617        });
618        let frags = AcpDialect::map_message(&tool);
619        assert!(
620            frags.iter().any(|f| matches!(
621                f,
622                AcpFragment::Tool {
623                    source_step: Some(3),
624                    source_time_ms: None,
625                    ..
626                }
627            )),
628            "tool stepIdx: {frags:?}"
629        );
630
631        let text = serde_json::json!({
632            "jsonrpc": "2.0",
633            "method": "session/update",
634            "params": {
635                "sessionId": "s",
636                "update": {
637                    "sessionUpdate": "agent_message_chunk",
638                    "messageId": "11",
639                    "content": { "type": "text", "text": "Done." }
640                }
641            }
642        });
643        let frags = AcpDialect::map_message(&text);
644        assert!(
645            matches!(
646                &frags[0],
647                AcpFragment::TextDelta {
648                    text,
649                    source_step: Some(11),
650                    source_time_ms: None,
651                    ..
652                } if text == "Done."
653            ),
654            "{frags:?}"
655        );
656    }
657
658    /// Grok Build live shape: null/absent status + rawInput on tool_call.
659    #[test]
660    fn grok_tool_call_without_status_is_ready_when_raw_input_present() {
661        let msg = serde_json::json!({
662            "jsonrpc": "2.0",
663            "method": "session/update",
664            "params": {
665                "sessionId": "s",
666                "update": {
667                    "sessionUpdate": "tool_call",
668                    "toolCallId": "call-1",
669                    "title": "write",
670                    "status": null,
671                    "rawInput": {
672                        "file_path": "/tmp/x.txt",
673                        "content": "hello\n"
674                    }
675                }
676            }
677        });
678        let frags = AcpDialect::map_message(&msg);
679        assert!(
680            frags.iter().any(|f| matches!(
681                f,
682                AcpFragment::Tool {
683                    signal: ToolSignal::RequestReady { tool_name, .. },
684                    ..
685                } if tool_name == "write"
686            )),
687            "{frags:?}"
688        );
689        assert!(
690            !frags.iter().any(|f| matches!(
691                f,
692                AcpFragment::Tool {
693                    signal: ToolSignal::Resolved { .. },
694                    ..
695                }
696            )),
697            "initial call must not resolve: {frags:?}"
698        );
699    }
700
701    /// Grok Build live shape: tool_call_update with content (diff) and no status
702    /// means tool outcome observed → Resolved (and Ready if args present).
703    #[test]
704    fn grok_tool_update_content_without_status_resolves() {
705        let msg = serde_json::json!({
706            "jsonrpc": "2.0",
707            "method": "session/update",
708            "params": {
709                "sessionId": "s",
710                "update": {
711                    "sessionUpdate": "tool_call_update",
712                    "toolCallId": "call-1",
713                    "title": "Write `/tmp/x.txt`",
714                    "status": null,
715                    "kind": "edit",
716                    "rawInput": {
717                        "file_path": "/tmp/x.txt",
718                        "content": "hello\n"
719                    },
720                    "content": [{
721                        "type": "diff",
722                        "path": "/tmp/x.txt",
723                        "oldText": "",
724                        "newText": "hello\n"
725                    }],
726                    "locations": []
727                }
728            }
729        });
730        let frags = AcpDialect::map_message(&msg);
731        assert!(
732            frags.iter().any(|f| matches!(
733                f,
734                AcpFragment::Tool {
735                    signal: ToolSignal::RequestReady { .. },
736                    ..
737                }
738            )),
739            "ready first: {frags:?}"
740        );
741        assert!(
742            frags.iter().any(|f| matches!(
743                f,
744                AcpFragment::Tool {
745                    signal: ToolSignal::Resolved {
746                        success: true,
747                        result_json: Some(j),
748                    },
749                    ..
750                } if j.contains("diff")
751            )),
752            "resolved with result: {frags:?}"
753        );
754    }
755
756    #[test]
757    fn explicit_failed_status_still_fails() {
758        let msg = serde_json::json!({
759            "jsonrpc": "2.0",
760            "method": "session/update",
761            "params": {
762                "update": {
763                    "sessionUpdate": "tool_call_update",
764                    "toolCallId": "call-2",
765                    "title": "bash",
766                    "status": "failed",
767                    "rawOutput": { "error": "boom" }
768                }
769            }
770        });
771        let frags = AcpDialect::map_message(&msg);
772        assert!(matches!(
773            &frags[0],
774            AcpFragment::Tool {
775                signal: ToolSignal::Resolved { success: false, .. },
776                ..
777            }
778        ));
779    }
780}