Skip to main content

speccoder_runtime/acp/
calls.rs

1//! What one message said about a tool call, and the call those messages
2//! add up to.
3//!
4//! The protocol lets every report after the first carry only what changed,
5//! and a permission request may name a call by its id alone. So a report is
6//! kept as sent — nothing filled in — and one record per call id is kept in
7//! which a field present replaces the stored value and a field absent or
8//! `null` leaves it alone. The policy reads that record.
9//!
10//! Adapted from devplane's `acp::calls` (same owner, MIT OR Apache-2.0).
11
12use std::path::PathBuf;
13
14/// One message's account of a tool call. Every field but the id is `None`
15/// when the message did not carry it.
16#[derive(Debug, Clone, Default, PartialEq)]
17pub struct ToolReport {
18    /// The call's id.
19    pub id: String,
20    /// For a person.
21    pub title: Option<String>,
22    /// The protocol's spelling: `execute`, `edit`, …
23    pub kind: Option<String>,
24    /// `pending`, `in_progress`, `completed`, `failed`.
25    pub status: Option<String>,
26    /// The arguments the agent passed.
27    pub raw_input: Option<serde_json::Value>,
28    /// `Some(vec![])` is *the agent said none*; `None` is *not said*.
29    pub locations: Option<Vec<PathBuf>>,
30}
31
32impl ToolReport {
33    /// From the protocol's own JSON shape of a tool call or an update: an
34    /// absent key and a `null` are the same `None`.
35    #[must_use]
36    pub fn from_wire(id: String, fields: &serde_json::Value) -> Self {
37        let text = |k: &str| fields.get(k).and_then(|v| v.as_str()).map(str::to_owned);
38        Self {
39            id,
40            title: text("title"),
41            kind: text("kind"),
42            status: text("status"),
43            raw_input: fields.get("rawInput").filter(|v| !v.is_null()).cloned(),
44            locations: fields
45                .get("locations")
46                .and_then(|v| v.as_array())
47                .map(|ls| {
48                    ls.iter()
49                        .filter_map(|l| l.get("path").and_then(|p| p.as_str()))
50                        .map(PathBuf::from)
51                        .collect()
52                }),
53        }
54    }
55}
56
57/// What an agent is asking to do, in the protocol's terms: the title is
58/// prose for a person; the kind, the raw input and the locations are what
59/// the policy reads — never the title.
60#[derive(Debug, Clone, Default, PartialEq)]
61pub struct ToolRequest {
62    /// The call's id.
63    pub id: String,
64    /// For a person; `None` until the agent sends one.
65    pub title: Option<String>,
66    /// `read`, `edit`, `delete`, `move`, `search`, `execute`, `think`,
67    /// `fetch`, `switch_mode`, `other`.
68    pub kind: Option<String>,
69    /// The arguments the agent passed.
70    pub raw_input: Option<serde_json::Value>,
71    /// Paths the call names.
72    pub locations: Vec<PathBuf>,
73    /// The latest status reported.
74    pub status: Option<String>,
75}
76
77impl ToolRequest {
78    /// Folds a later report in: a field it carries replaces the stored one;
79    /// a field it omits leaves it alone.
80    pub fn merge(&mut self, r: &ToolReport) {
81        if let Some(t) = &r.title {
82            self.title = Some(t.chars().take(200).collect());
83        }
84        if let Some(k) = &r.kind {
85            self.kind = Some(k.clone());
86        }
87        if let Some(i) = &r.raw_input {
88            self.raw_input = Some(i.clone());
89        }
90        if let Some(l) = &r.locations {
91            self.locations.clone_from(l);
92        }
93        if let Some(s) = &r.status {
94            self.status = Some(s.clone());
95        }
96    }
97
98    /// The title for a person: the agent's, or `untitled call <id>`.
99    #[must_use]
100    pub fn shown_title(&self) -> String {
101        match self.title.as_deref() {
102            Some(t) if !t.trim().is_empty() => t.to_owned(),
103            _ => format!("untitled call {}", self.id),
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use serde_json::json;
112
113    fn wire(v: serde_json::Value) -> ToolReport {
114        ToolReport::from_wire("c1".into(), &v)
115    }
116
117    #[test]
118    fn a_present_field_replaces_and_an_absent_or_null_one_keeps() {
119        let mut call = ToolRequest {
120            id: "c1".into(),
121            ..ToolRequest::default()
122        };
123        call.merge(&wire(json!({
124            "title": "Run a command", "kind": "execute",
125            "rawInput": {"command": "ls"}, "locations": [{"path": "/a"}]
126        })));
127        call.merge(&wire(json!({"rawInput": {"command": "rm -rf build"}})));
128        assert_eq!(call.title.as_deref(), Some("Run a command"));
129        assert_eq!(call.raw_input, Some(json!({"command": "rm -rf build"})));
130        call.merge(&wire(
131            json!({"title": null, "kind": null, "rawInput": null, "locations": null}),
132        ));
133        assert_eq!(call.kind.as_deref(), Some("execute"), "null keeps");
134        assert_eq!(call.locations, vec![PathBuf::from("/a")]);
135        call.merge(&wire(json!({"locations": []})));
136        assert!(call.locations.is_empty(), "an empty list is said");
137        assert_eq!(
138            ToolRequest {
139                id: "t9".into(),
140                ..ToolRequest::default()
141            }
142            .shown_title(),
143            "untitled call t9"
144        );
145    }
146}