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