1use leviath_core::interaction::{InteractionKind, InteractionRequest};
8use leviath_core::run_meta::RunStatus;
9
10use crate::protocol::{
11 ContentBlock, PermissionOption, PermissionOptionKind, RequestPermissionParams, StopReason,
12 ToolCallRef, ToolCallStatus, ToolKind,
13};
14
15pub const OPTION_ALLOW_ONCE: &str = "allow-once";
17pub const OPTION_ALLOW_ALWAYS: &str = "allow-always";
19pub const OPTION_REJECT_ONCE: &str = "reject-once";
21
22pub fn flatten_prompt(blocks: &[ContentBlock]) -> String {
36 let mut parts: Vec<String> = Vec::new();
37 for block in blocks {
38 match block.kind.as_str() {
39 "text" => {
40 if let Some(text) = block
41 .text
42 .as_deref()
43 .map(str::trim)
44 .filter(|t| !t.is_empty())
45 {
46 parts.push(text.to_string());
47 }
48 }
49 "resource" => {
50 if let Some(resource) = &block.resource
51 && let Some(text) = resource.text.as_deref()
52 {
53 parts.push(format!("--- {} ---\n{}", resource.uri, text));
54 }
55 }
56 _ => {}
57 }
58 }
59 parts.join("\n\n").trim().to_string()
60}
61
62pub fn parse_region_markers(text: &str) -> std::collections::HashMap<String, String> {
74 use std::collections::HashMap;
75
76 let marker_name = |line: &str| -> Option<String> {
77 let t = line.trim();
78 t.strip_prefix("---region:")
79 .and_then(|rest| rest.strip_suffix("---"))
80 .map(|n| n.trim().to_string())
81 };
82
83 let mut out = HashMap::new();
84 let mut current: Option<String> = None;
87 let mut buf: Vec<&str> = Vec::new();
88 let mut ended = false;
89 let mut saw_marker = false;
90
91 let flush = |name: &Option<String>, buf: &mut Vec<&str>, out: &mut HashMap<String, String>| {
92 let body = buf.join("\n");
93 let body = body.trim();
94 if !body.is_empty() {
95 let key = name.clone().unwrap_or_else(|| "task".to_string());
96 out.insert(key, body.to_string());
97 }
98 buf.clear();
99 };
100
101 for line in text.lines() {
102 if ended {
103 break;
104 }
105 if line.trim() == "---end-regions---" {
106 flush(¤t, &mut buf, &mut out);
107 ended = true;
108 continue;
109 }
110 if let Some(name) = marker_name(line) {
111 flush(¤t, &mut buf, &mut out);
112 current = Some(name);
113 saw_marker = true;
114 continue;
115 }
116 buf.push(line);
117 }
118 if !ended {
119 flush(¤t, &mut buf, &mut out);
120 }
121
122 if !saw_marker {
123 let mut out = HashMap::new();
125 let trimmed = text.trim();
126 if !trimmed.is_empty() {
127 out.insert("task".to_string(), trimmed.to_string());
128 }
129 return out;
130 }
131 out
132}
133
134pub fn stop_reason_for(status: &RunStatus) -> Option<StopReason> {
143 match status {
144 RunStatus::Complete | RunStatus::CompleteInteractive => Some(StopReason::EndTurn),
145 RunStatus::Error => Some(StopReason::Refusal),
146 RunStatus::Cancelled => Some(StopReason::Cancelled),
147 RunStatus::Starting | RunStatus::Running | RunStatus::WaitingInput | RunStatus::Paused => {
148 None
149 }
150 }
151}
152
153pub fn stop_reason_for_label(status: &str) -> StopReason {
157 match status {
158 "cancelled" => StopReason::Cancelled,
159 "error" => StopReason::Refusal,
160 _ => StopReason::EndTurn,
161 }
162}
163
164pub fn permission_request(
173 session_id: &str,
174 request: &InteractionRequest,
175) -> RequestPermissionParams {
176 RequestPermissionParams {
177 session_id: session_id.to_string(),
178 tool_call: ToolCallRef {
179 tool_call_id: request.id.clone(),
180 title: permission_title(request),
181 kind: tool_kind_for(request.tool_name.as_deref()),
182 status: ToolCallStatus::Pending,
183 },
184 options: vec![
185 PermissionOption {
186 option_id: OPTION_ALLOW_ONCE.to_string(),
187 name: "Allow once".to_string(),
188 kind: PermissionOptionKind::AllowOnce,
189 },
190 PermissionOption {
191 option_id: OPTION_ALLOW_ALWAYS.to_string(),
192 name: "Allow for this session".to_string(),
193 kind: PermissionOptionKind::AllowAlways,
194 },
195 PermissionOption {
196 option_id: OPTION_REJECT_ONCE.to_string(),
197 name: "Reject".to_string(),
198 kind: PermissionOptionKind::RejectOnce,
199 },
200 ],
201 }
202}
203
204fn permission_title(request: &InteractionRequest) -> String {
207 match request.tool_name.as_deref() {
208 Some(name) => name.to_string(),
209 None => request.prompt.clone(),
210 }
211}
212
213fn tool_kind_for(tool_name: Option<&str>) -> ToolKind {
219 match tool_name {
220 Some("read_file" | "read_files" | "list_files" | "grep") => ToolKind::Read,
221 Some("write_file" | "edit_file" | "apply_patch") => ToolKind::Edit,
222 Some("delete_file") => ToolKind::Delete,
223 Some("move_file") => ToolKind::Move,
224 Some("search" | "web_search") => ToolKind::Search,
225 Some("bash" | "run_command" | "shell") => ToolKind::Execute,
226 Some("fetch" | "web_fetch" | "http_get") => ToolKind::Fetch,
227 _ => ToolKind::Other,
228 }
229}
230
231pub fn is_permission_request(request: &InteractionRequest) -> bool {
239 matches!(request.kind, InteractionKind::ToolApproval)
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::protocol::EmbeddedResource;
246
247 fn text_block(text: &str) -> ContentBlock {
248 ContentBlock::text(text)
249 }
250
251 fn resource_block(uri: &str, text: Option<&str>) -> ContentBlock {
252 ContentBlock {
253 kind: "resource".to_string(),
254 text: None,
255 resource: Some(EmbeddedResource {
256 uri: uri.to_string(),
257 mime_type: None,
258 text: text.map(str::to_string),
259 }),
260 }
261 }
262
263 fn approval(id: &str, tool: Option<&str>) -> InteractionRequest {
264 InteractionRequest {
265 id: id.to_string(),
266 kind: InteractionKind::ToolApproval,
267 prompt: "Run this?".to_string(),
268 options: vec![],
269 tool_name: tool.map(str::to_string),
270 tool_arguments: None,
271 required: true,
272 stage_name: "implement".to_string(),
273 body: None,
274 body_format: Default::default(),
275 }
276 }
277
278 #[test]
281 fn markers_absent_puts_whole_text_in_task() {
282 let out = parse_region_markers("just do the thing");
283 assert_eq!(out.len(), 1);
284 assert_eq!(
285 out.get("task").map(String::as_str),
286 Some("just do the thing")
287 );
288 }
289
290 #[test]
291 fn markers_absent_empty_text_yields_empty_map() {
292 assert!(parse_region_markers(" \n ").is_empty());
293 }
294
295 #[test]
296 fn leading_text_before_first_marker_becomes_task() {
297 let text = "build a parser\n---region:criteria---\nfocus on safety";
298 let out = parse_region_markers(text);
299 assert_eq!(out.get("task").map(String::as_str), Some("build a parser"));
300 assert_eq!(
301 out.get("criteria").map(String::as_str),
302 Some("focus on safety")
303 );
304 }
305
306 #[test]
307 fn multiple_regions_and_end_marker_with_trailing_text_dropped() {
308 let text = "\
309---region:task---
310build it
311---region:criteria---
312be careful
313---end-regions---
314this trailing text is ignored";
315 let out = parse_region_markers(text);
316 assert_eq!(out.get("task").map(String::as_str), Some("build it"));
317 assert_eq!(out.get("criteria").map(String::as_str), Some("be careful"));
318 assert_eq!(out.len(), 2, "trailing text after end marker is dropped");
319 }
320
321 #[test]
322 fn empty_region_blocks_are_dropped() {
323 let text = "---region:task---\nreal\n---region:empty---\n \n";
324 let out = parse_region_markers(text);
325 assert_eq!(out.get("task").map(String::as_str), Some("real"));
326 assert!(!out.contains_key("empty"));
327 }
328
329 #[test]
332 fn flatten_joins_text_blocks_with_a_blank_line() {
333 assert_eq!(
334 flatten_prompt(&[text_block("first"), text_block("second")]),
335 "first\n\nsecond"
336 );
337 }
338
339 #[test]
340 fn flatten_of_a_single_block_is_just_its_text() {
341 assert_eq!(flatten_prompt(&[text_block("only")]), "only");
342 }
343
344 #[test]
345 fn flatten_skips_blank_and_whitespace_only_text_blocks() {
346 assert_eq!(
347 flatten_prompt(&[text_block(""), text_block(" \n "), text_block("real")]),
348 "real"
349 );
350 }
351
352 #[test]
353 fn flatten_trims_each_text_block() {
354 assert_eq!(flatten_prompt(&[text_block(" padded ")]), "padded");
355 }
356
357 #[test]
358 fn flatten_skips_a_text_block_with_no_text_field() {
359 let block = ContentBlock {
360 kind: "text".to_string(),
361 text: None,
362 resource: None,
363 };
364 assert_eq!(flatten_prompt(&[block, text_block("kept")]), "kept");
365 }
366
367 #[test]
368 fn flatten_headers_resource_blocks_with_their_uri() {
369 assert_eq!(
370 flatten_prompt(&[
371 text_block("review this"),
372 resource_block("file:///a.rs", Some("fn main() {}")),
373 ]),
374 "review this\n\n--- file:///a.rs ---\nfn main() {}"
375 );
376 }
377
378 #[test]
379 fn flatten_skips_a_resource_block_with_no_inlined_text() {
380 assert_eq!(
381 flatten_prompt(&[resource_block("file:///a.rs", None), text_block("kept")]),
382 "kept"
383 );
384 }
385
386 #[test]
387 fn flatten_skips_a_resource_block_with_no_resource_field() {
388 let block = ContentBlock {
389 kind: "resource".to_string(),
390 text: None,
391 resource: None,
392 };
393 assert_eq!(flatten_prompt(&[block, text_block("kept")]), "kept");
394 }
395
396 #[test]
397 fn flatten_drops_unsupported_block_kinds() {
398 let image = ContentBlock {
399 kind: "image".to_string(),
400 text: Some("ignored".to_string()),
401 resource: None,
402 };
403 assert_eq!(flatten_prompt(&[image, text_block("kept")]), "kept");
404 }
405
406 #[test]
407 fn flatten_of_nothing_usable_is_empty() {
408 assert_eq!(flatten_prompt(&[]), "");
409 let audio = ContentBlock {
410 kind: "audio".to_string(),
411 text: None,
412 resource: None,
413 };
414 assert_eq!(flatten_prompt(&[audio]), "");
415 }
416
417 #[test]
420 fn stop_reason_maps_every_run_status() {
421 for (status, expected) in [
422 (RunStatus::Starting, None),
423 (RunStatus::Running, None),
424 (RunStatus::WaitingInput, None),
425 (RunStatus::Paused, None),
426 (RunStatus::Complete, Some(StopReason::EndTurn)),
427 (RunStatus::CompleteInteractive, Some(StopReason::EndTurn)),
428 (RunStatus::Error, Some(StopReason::Refusal)),
429 (RunStatus::Cancelled, Some(StopReason::Cancelled)),
430 ] {
431 assert_eq!(stop_reason_for(&status), expected, "status {status}");
432 }
433 }
434
435 #[test]
436 fn stop_reason_label_matches_the_status_mapping() {
437 assert_eq!(stop_reason_for_label("cancelled"), StopReason::Cancelled);
438 assert_eq!(stop_reason_for_label("error"), StopReason::Refusal);
439 assert_eq!(stop_reason_for_label("complete"), StopReason::EndTurn);
440 assert_eq!(stop_reason_for_label("anything-else"), StopReason::EndTurn);
441 }
442
443 #[test]
446 fn permission_request_offers_once_session_and_reject() {
447 let params = permission_request("s1", &approval("q1", Some("bash")));
448 assert_eq!(params.session_id, "s1");
449 assert_eq!(params.tool_call.tool_call_id, "q1");
450 assert_eq!(params.tool_call.title, "bash");
451 assert_eq!(params.tool_call.kind, ToolKind::Execute);
452 assert_eq!(params.tool_call.status, ToolCallStatus::Pending);
453 let ids: Vec<&str> = params
454 .options
455 .iter()
456 .map(|o| o.option_id.as_str())
457 .collect();
458 assert_eq!(
459 ids,
460 [OPTION_ALLOW_ONCE, OPTION_ALLOW_ALWAYS, OPTION_REJECT_ONCE]
461 );
462 let kinds: Vec<PermissionOptionKind> = params.options.iter().map(|o| o.kind).collect();
463 assert_eq!(
464 kinds,
465 [
466 PermissionOptionKind::AllowOnce,
467 PermissionOptionKind::AllowAlways,
468 PermissionOptionKind::RejectOnce,
469 ]
470 );
471 }
472
473 #[test]
474 fn permission_request_falls_back_to_the_prompt_when_there_is_no_tool_name() {
475 let params = permission_request("s1", &approval("q1", None));
476 assert_eq!(params.tool_call.title, "Run this?");
477 assert_eq!(params.tool_call.kind, ToolKind::Other);
478 }
479
480 #[test]
481 fn tool_kinds_cover_every_classification_arm() {
482 for (name, expected) in [
483 ("read_file", ToolKind::Read),
484 ("read_files", ToolKind::Read),
485 ("list_files", ToolKind::Read),
486 ("grep", ToolKind::Read),
487 ("write_file", ToolKind::Edit),
488 ("edit_file", ToolKind::Edit),
489 ("apply_patch", ToolKind::Edit),
490 ("delete_file", ToolKind::Delete),
491 ("move_file", ToolKind::Move),
492 ("search", ToolKind::Search),
493 ("web_search", ToolKind::Search),
494 ("bash", ToolKind::Execute),
495 ("run_command", ToolKind::Execute),
496 ("shell", ToolKind::Execute),
497 ("fetch", ToolKind::Fetch),
498 ("web_fetch", ToolKind::Fetch),
499 ("http_get", ToolKind::Fetch),
500 ("mcp__whatever__thing", ToolKind::Other),
501 ] {
502 assert_eq!(tool_kind_for(Some(name)), expected, "tool {name}");
503 }
504 assert_eq!(tool_kind_for(None), ToolKind::Other);
505 }
506
507 #[test]
510 fn only_tool_approvals_are_permission_requests() {
511 assert!(is_permission_request(&approval("q", Some("bash"))));
512 for kind in [
513 InteractionKind::FreeText,
514 InteractionKind::MultipleChoice,
515 InteractionKind::Confirm,
516 InteractionKind::EditText,
517 ] {
518 let mut req = approval("q", None);
519 req.kind = kind;
520 assert!(!is_permission_request(&req));
521 }
522 }
523}