1use agentplane::core::{Decided, Decision, Justification, Operator, Tainted, TaskSpec};
10use serde_json::{Value, json};
11
12use super::{Answer, Decision as Said, PresentContext, Presentation, Request, Scope, Who};
13
14pub const DEADLINE: &str = "approval";
16
17pub const APPROVAL: &str = "approval";
19
20pub const QUESTION: &str = "question";
22
23#[must_use]
29pub fn approval_task(request: &Request, log: &[Value]) -> TaskSpec {
30 TaskSpec::new(
31 APPROVAL,
32 Justification::new(
33 Tainted::trusted(format!("{} — {}", request.task, request.covers())),
34 json!({
35 "tool": request.tool,
36 "arguments": request.arguments,
37 "task": request.task,
38 "object": request.object,
39 "request": request,
40 "log": log,
42 }),
43 ),
44 DEADLINE,
45 )
46}
47
48#[must_use]
50pub fn question_task(task: &str, question: &str) -> TaskSpec {
51 TaskSpec::new(
52 QUESTION,
53 Justification::new(
54 Tainted::trusted(format!("{task} asks")),
55 json!({ "task": task, "question": question }),
56 ),
57 DEADLINE,
58 )
59}
60
61#[must_use]
63pub fn request_of(proposed_action: &Value) -> Option<Request> {
64 serde_json::from_value(proposed_action["request"].clone()).ok()
65}
66
67#[must_use]
69pub fn presentation_of(proposed_action: &Value, context: &PresentContext) -> Option<Presentation> {
70 request_of(proposed_action).map(|r| super::present(&r, context))
71}
72
73#[must_use]
76pub fn decision_for(answer: &Answer, by: &Who, text: Option<&str>) -> Option<Decision> {
77 let operator: Operator = by.operator()?;
78 let mut amendment = json!({ "scope": answer.scope.name(), "via": by.via });
79 if answer.always {
80 amendment["always"] = json!(true);
81 }
82 if answer.resume_asking {
83 amendment["resume_asking"] = json!(true);
84 }
85 if let Some(client) = &by.client {
86 amendment["client"] = json!(client);
87 }
88 if let Some(note) = &answer.note {
89 amendment["note"] = json!(note);
90 }
91 if let Some(text) = text {
92 amendment["answer"] = json!(text);
93 }
94 Some(
95 match answer.decision {
96 Said::Approve => Decision::approve(operator, answer.note.clone().unwrap_or_default()),
97 Said::Reject | Said::Stop => Decision::reject(
98 operator,
99 answer
100 .reason
101 .clone()
102 .unwrap_or_else(|| "no reason given".to_owned()),
103 ),
104 }
105 .amend(amendment),
106 )
107}
108
109#[must_use]
114pub fn ask(
115 approver: &dyn super::Approver,
116 task: &agentplane::core::Task,
117 context: &PresentContext,
118) -> Option<(Decision, Answer)> {
119 let action = &task.justification.proposed_action;
120 match task.kind.as_str() {
121 APPROVAL => {
122 let presentation = presentation_of(action, context)?;
123 if presentation.request.batched {
124 return None;
125 }
126 let mut answer = approver.ask(&presentation);
127 if answer.decision == Said::Stop {
128 return None;
129 }
130 if answer.always && !presentation.request.options.iter().any(|o| o == "always") {
132 answer.always = false;
133 }
134 let by = named(answer.by.clone(), approver, context);
135 answer.by = Some(by.clone());
136 decision_for(&answer, &by, None).map(|d| (d, answer))
137 }
138 QUESTION => {
139 let by = named(None, approver, context);
140 let answer = match approver.answer(action["question"].as_str()?) {
141 Some(text) => {
142 let a = Answer::approve(Scope::Once, None, Some(by.clone()));
143 return decision_for(&a, &by, Some(&text)).map(|d| (d, a));
144 }
145 None => Answer::reject("declined to answer", Some(by.clone())),
146 };
147 decision_for(&answer, &by, None).map(|d| (d, answer))
148 }
149 _ => None,
150 }
151}
152
153fn named(by: Option<Who>, approver: &dyn super::Approver, context: &PresentContext) -> Who {
155 let mut by = by.unwrap_or_else(|| match approver.client() {
156 Some(client) => Who::connected(&client),
157 None => Who::local(approver.via()),
158 });
159 if let Some(root) = &context.root {
160 by.name = super::who_in(root);
161 }
162 by
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Read {
169 pub answer: Answer,
171 pub by: Option<Who>,
173 pub expired: bool,
175 pub text: Option<String>,
177 pub resume_asking: bool,
180}
181
182#[must_use]
184pub fn read(d: &Decision) -> Read {
185 let a = &d.amendment;
186 let scope = match a["scope"].as_str() {
187 Some("once") => Scope::Once,
188 Some("run") => Scope::Run,
189 _ => Scope::Task,
190 };
191 let by = d.decided.operator().and_then(|op| {
194 serde_json::from_value::<Who>(json!({
195 "name": op.actor(),
196 "via": a["via"].as_str().unwrap_or("terminal"),
197 "client": a["client"],
198 "basis": op.basis().to_string(),
199 }))
200 .ok()
201 });
202 let expired = matches!(d.decided, Decided::OnExpiry(_));
203 let answer = if d.approved {
204 let mut answer = Answer::approve(scope, a["note"].as_str().map(str::to_owned), by.clone());
205 answer.always = a["always"] == true;
206 answer
207 } else {
208 Answer::reject(
209 if expired {
210 "not approved before its deadline"
211 } else {
212 d.reason.as_str()
213 },
214 by.clone(),
215 )
216 };
217 Read {
218 answer,
219 by,
220 expired,
221 text: a["answer"].as_str().map(str::to_owned),
222 resume_asking: a["resume_asking"] == true,
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn who() -> Who {
231 Who {
232 name: "dana".to_owned(),
233 via: "terminal".to_owned(),
234 client: None,
235 basis: "asserted".to_owned(),
236 }
237 }
238
239 #[test]
240 fn a_decision_reads_back_with_its_origin_also_one_recorded_before() {
241 let editor = Who {
242 name: "dana".to_owned(),
243 via: "acp".to_owned(),
244 client: Some("Zed".to_owned()),
245 basis: "connected".to_owned(),
246 };
247 let answer = Answer::approve(Scope::Run, None, Some(editor.clone()));
248 let mut d = decision_for(&answer, &editor, None).unwrap();
249 assert_eq!(read(&d).by.as_ref(), Some(&editor));
250 d.amendment = json!({ "scope": "run", "via": "editor Zed" });
252 let old = read(&d).by.unwrap();
253 assert_eq!(
254 (old.via.as_str(), old.client.as_deref()),
255 ("acp", Some("Zed"))
256 );
257 d.amendment = json!({ "scope": "run", "via": "dashboard" });
258 assert_eq!(read(&d).by.unwrap().via, "terminal");
259 }
260
261 #[test]
262 fn every_scope_and_note_survive_the_record() {
263 for scope in [Scope::Once, Scope::Task, Scope::Run] {
264 let answer = Answer::approve(scope, Some("keep it small".to_owned()), Some(who()));
265 let d = decision_for(&answer, &who(), None).unwrap();
266 let back = read(&d);
267 assert_eq!(back.answer.scope, scope);
268 assert_eq!(back.answer.note.as_deref(), Some("keep it small"));
269 assert_eq!(back.by, Some(who()));
270 assert!(!back.expired);
271 }
272 let rejected = Answer::reject("not that file", Some(who()));
273 let back = read(&decision_for(&rejected, &who(), None).unwrap());
274 assert_eq!(back.answer.decision, Said::Reject);
275 assert_eq!(back.answer.reason.as_deref(), Some("not that file"));
276 }
277
278 #[test]
279 fn an_expiry_is_a_refusal_nobody_made() {
280 let d = Decision::expired(agentplane::core::OnExpiry::Deny);
281 let back = read(&d);
282 assert!(back.expired && back.by.is_none());
283 assert_eq!(back.answer.decision, Said::Reject);
284 }
285
286 #[test]
287 fn the_task_carries_the_exact_call() {
288 let request = super::super::Request {
289 task: "001-x/T001".to_owned(),
290 task_title: "t".to_owned(),
291 turn: 1,
292 tool: "write_file".to_owned(),
293 arguments: json!({ "path": "src/a.rs", "content": "fn a() {}\n" }),
294 kind: "write".to_owned(),
295 reason: "r".to_owned(),
296 object: Some(crate::tools::policy::Object::Path {
297 canonical: "src/a.rs".to_owned(),
298 asked: "src/a.rs".to_owned(),
299 }),
300 risk: "low".to_owned(),
301 options: vec!["once".to_owned()],
302 ..super::super::Request::default()
303 };
304 let spec = approval_task(&request, &[]);
305 assert_eq!(spec.kind, APPROVAL);
306 let action = &spec.justification.proposed_action;
307 assert_eq!(action["arguments"]["content"], "fn a() {}\n");
308 assert_eq!(request_of(action), Some(request.clone()));
309 assert_eq!(
311 spec.justification.digest(),
312 approval_task(&request, &[]).justification.digest()
313 );
314 }
315
316 #[test]
317 fn a_question_recorded_before_objects_never_matches_the_rebuilt_one() {
318 let request = super::super::Request {
319 task: "001-x/T001".to_owned(),
320 tool: "write_file".to_owned(),
321 arguments: json!({ "path": "package.json", "content": "{}" }),
322 kind: "write".to_owned(),
323 reason: "a dependency manifest".to_owned(),
324 object: Some(crate::tools::policy::Object::Path {
325 canonical: "package.json".to_owned(),
326 asked: "package.json".to_owned(),
327 }),
328 risk: "medium".to_owned(),
329 ..super::super::Request::default()
330 };
331 let now = approval_task(&request, &[]);
332 let old = Justification::new(
334 Tainted::trusted(format!("{} — write `package.json`", request.task)),
335 json!({
336 "tool": request.tool,
337 "arguments": request.arguments,
338 "task": request.task,
339 "grant": { "kind": "write", "path": "package.json" },
340 "request": request,
341 "log": [],
342 }),
343 );
344 assert_ne!(old.digest(), now.justification.digest());
345 }
346}