1use monoloop_contracts::{TextChannel, ToolActionId};
7use serde_json::Value;
8
9#[derive(Clone, Debug)]
11pub enum AcpFragment {
12 TextDelta {
14 channel: TextChannel,
16 text: String,
18 source_time_ms: Option<u64>,
20 source_step: Option<u64>,
22 },
23 Tool {
25 action_id: ToolActionId,
27 signal: ToolSignal,
29 source_time_ms: Option<u64>,
31 source_step: Option<u64>,
33 },
34 ResponseFinished,
36 Diagnostic {
38 message: String,
40 },
41}
42
43#[derive(Clone, Debug)]
45pub enum ToolSignal {
46 Waiting {
48 tool_name: Option<String>,
50 waiting_for: String,
52 },
53 RequestReady {
55 tool_name: String,
57 arguments_json: String,
59 },
60 Resolved {
62 success: bool,
64 result_json: Option<String>,
66 },
67}
68
69pub struct AcpDialect;
71
72impl AcpDialect {
73 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/update_todos" | "cursor/task" | "cursor/generate_image" => {
85 out.push(AcpFragment::Diagnostic {
86 message: format!("cursor extension notification: {method}"),
87 });
88 }
89 _ => {
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 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 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 }
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 "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 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
190fn 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 params
203 .get("agentTimestampMs")
204 .and_then(|t| t.as_u64().or_else(|| t.as_i64().map(|i| i as u64)))
205 })
206}
207
208fn 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 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 .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 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 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 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
419fn 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
430pub fn drain_json_values(buffer: &mut Vec<u8>) -> Result<Vec<Value>, String> {
434 let mut out = Vec::new();
435 loop {
436 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 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 #[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 #[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 #[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}