1use std::collections::{BTreeMap, VecDeque};
4use std::net::TcpListener;
5use std::path::{Path, PathBuf};
6use std::process::Stdio;
7use std::sync::Arc;
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use async_trait::async_trait;
11use futures::StreamExt;
12use serde_json::{json, Value};
13use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
14use tokio::process::{Child, ChildStdin, Command};
15use tokio::sync::{mpsc, Mutex};
16
17use super::{
18 BearerToken, HarnessEvent, JsonLineClient, McpServerLaunch, RuntimeAttachRequest,
19 RuntimeBackend, RuntimeCapabilities, RuntimeConnection, RuntimeEndpoint, RuntimeHandle,
20 RuntimeInput, RuntimeLaunch, RuntimeStartRequest,
21};
22use crate::{Error, HarnessId, Result};
23
24#[derive(Debug, Clone)]
26pub struct PiRuntimeBackend {
27 launch: RuntimeLaunch,
28}
29
30impl Default for PiRuntimeBackend {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl PiRuntimeBackend {
37 pub fn new() -> Self {
39 Self {
40 launch: RuntimeLaunch {
41 program: "pi".into(),
42 arguments: vec!["--mode".into(), "rpc".into()],
43 env: BTreeMap::new(),
44 },
45 }
46 }
47
48 pub fn with_launch(launch: RuntimeLaunch) -> Self {
50 Self { launch }
51 }
52
53 async fn open(
54 &self,
55 cwd: &Path,
56 runtime_id: String,
57 launch: Option<RuntimeLaunch>,
58 resume: bool,
59 ) -> Result<Box<dyn RuntimeConnection>> {
60 let mut launch = launch.unwrap_or_else(|| self.launch.clone());
61 if resume {
62 launch
63 .arguments
64 .extend(["--session".into(), runtime_id.clone()]);
65 } else {
66 launch
67 .arguments
68 .extend(["--session-id".into(), runtime_id.clone()]);
69 }
70 let transport = RawLineTransport::spawn(&launch, Some(cwd), "pi-rpc-jsonl").await?;
71 let handle = RuntimeHandle {
72 harness: HarnessId::from(HarnessId::PI),
73 runtime_id,
74 endpoint: transport.endpoint.clone(),
75 };
76 Ok(Box::new(PiRuntimeConnection {
77 handle,
78 transport,
79 next_request: 1,
80 }))
81 }
82}
83
84#[async_trait]
85impl RuntimeBackend for PiRuntimeBackend {
86 fn harness(&self) -> HarnessId {
87 HarnessId::from(HarnessId::PI)
88 }
89
90 fn capabilities(&self) -> RuntimeCapabilities {
91 RuntimeCapabilities {
92 start_session: true,
93 resume_session: true,
94 attach_existing_process: false,
95 send_input: true,
96 stream_events: true,
97 interrupt: true,
98 steer: false,
99 respond_to_requests: true,
100 }
101 }
102
103 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
104 self.open(&request.cwd, generated_session_id(), request.launch, false)
105 .await
106 }
107
108 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
109 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
110 self.open(&cwd, request.runtime_id, request.launch, true)
111 .await
112 }
113}
114
115struct PiRuntimeConnection {
116 handle: RuntimeHandle,
117 transport: RawLineTransport,
118 next_request: u64,
119}
120
121#[async_trait]
122impl RuntimeConnection for PiRuntimeConnection {
123 fn handle(&self) -> &RuntimeHandle {
124 &self.handle
125 }
126
127 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
128 if !input.image_urls.is_empty() {
129 return Err(Error::Other(
130 "Pi RPC image input is not verified by the installed protocol contract".into(),
131 ));
132 }
133 let id = format!("supercode-{}", self.next_request);
134 self.next_request += 1;
135 self.transport
136 .write(json!({"id": id, "type": "prompt", "message": input.text}))
137 .await?;
138 Ok(Some(id))
139 }
140
141 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
142 raw_next_event(&mut self.transport.receiver).await
143 }
144
145 async fn interrupt(&mut self) -> Result<()> {
146 self.transport.write(json!({"type": "abort"})).await
147 }
148
149 async fn respond(&mut self, request_id: Value, mut response: Value) -> Result<()> {
150 if let Value::Object(object) = &mut response {
151 object.entry("id").or_insert(request_id);
152 self.transport.write(response).await
153 } else {
154 self.transport
155 .write(json!({"id": request_id, "response": response}))
156 .await
157 }
158 }
159
160 async fn close(&mut self) -> Result<()> {
161 self.transport.close().await
162 }
163}
164
165#[derive(Debug, Clone)]
172pub struct ClaudeCodeRuntimeBackend {
173 launch: RuntimeLaunch,
174 permission_timeout: Duration,
175}
176
177impl Default for ClaudeCodeRuntimeBackend {
178 fn default() -> Self {
179 Self::new()
180 }
181}
182
183impl ClaudeCodeRuntimeBackend {
184 pub fn new() -> Self {
187 Self {
188 launch: RuntimeLaunch {
189 program: "claude".into(),
190 arguments: vec![
191 "--print".into(),
192 "--input-format".into(),
193 "stream-json".into(),
194 "--output-format".into(),
195 "stream-json".into(),
196 "--verbose".into(),
197 "--permission-prompt-tool".into(),
206 "stdio".into(),
207 ],
208 env: BTreeMap::new(),
209 },
210 permission_timeout: CLAUDE_PERMISSION_RESPONSE_TIMEOUT,
211 }
212 }
213
214 pub fn launch(&self) -> &RuntimeLaunch {
219 &self.launch
220 }
221
222 pub fn with_launch(launch: RuntimeLaunch) -> Self {
224 Self {
225 launch,
226 permission_timeout: CLAUDE_PERMISSION_RESPONSE_TIMEOUT,
227 }
228 }
229
230 pub fn with_permission_timeout(mut self, timeout: Duration) -> Self {
236 self.permission_timeout = timeout;
237 self
238 }
239
240 async fn open(
241 &self,
242 cwd: &Path,
243 runtime_id: String,
244 launch: Option<RuntimeLaunch>,
245 resume: bool,
246 ) -> Result<Box<dyn RuntimeConnection>> {
247 let prefix = launch.unwrap_or_else(|| self.launch.clone());
251 let mut launch = prefix.clone();
252 launch.arguments.extend(if resume {
253 vec!["--resume".into(), runtime_id.clone()]
254 } else {
255 vec!["--session-id".into(), runtime_id.clone()]
256 });
257 let transport = RawLineTransport::spawn(&launch, Some(cwd), "claude-stream-json").await?;
258 Ok(Box::new(ClaudeRuntimeConnection {
259 handle: RuntimeHandle {
260 harness: HarnessId::from(HarnessId::CLAUDE_CODE),
261 runtime_id,
262 endpoint: transport.endpoint.clone(),
263 },
264 transport,
265 prefix,
266 cwd: cwd.to_path_buf(),
267 spoke: false,
268 buffered_events: VecDeque::new(),
269 next_control_request: 1,
270 control_timeout: CLAUDE_CONTROL_RESPONSE_TIMEOUT,
271 pending_permissions: Vec::new(),
272 permission_timeout: self.permission_timeout,
273 }))
274 }
275}
276
277const CLAUDE_CONTROL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
287
288pub const CLAUDE_PERMISSION_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300);
301
302const CLAUDE_PERMISSION_BEHAVIORS: [&str; 2] = ["allow", "deny"];
309
310const CLAUDE_PERMISSION_TIMEOUT_MESSAGE: &str =
312 "supercode denied this permission request: no answer arrived before the adapter's \
313 permission timeout elapsed";
314
315#[async_trait]
316impl RuntimeBackend for ClaudeCodeRuntimeBackend {
317 fn harness(&self) -> HarnessId {
318 HarnessId::from(HarnessId::CLAUDE_CODE)
319 }
320
321 fn capabilities(&self) -> RuntimeCapabilities {
322 RuntimeCapabilities {
323 start_session: true,
324 resume_session: true,
325 attach_existing_process: false,
326 send_input: true,
327 stream_events: true,
328 interrupt: true,
329 steer: true,
330 respond_to_requests: true,
331 }
332 }
333
334 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
335 self.open(&request.cwd, generated_session_id(), request.launch, false)
336 .await
337 }
338
339 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
340 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
341 self.open(&cwd, request.runtime_id, request.launch, true)
342 .await
343 }
344}
345
346struct ClaudeRuntimeConnection {
347 handle: RuntimeHandle,
348 transport: RawLineTransport,
349 prefix: RuntimeLaunch,
353 cwd: PathBuf,
357 spoke: bool,
362 buffered_events: VecDeque<Value>,
366 next_control_request: u64,
367 control_timeout: Duration,
368 pending_permissions: Vec<PendingPermission>,
372 permission_timeout: Duration,
373}
374
375struct PendingPermission {
377 request_id: String,
379 deadline: tokio::time::Instant,
381}
382
383impl ClaudeRuntimeConnection {
384 async fn process_ended(&self) -> bool {
391 matches!(self.transport.child.lock().await.try_wait(), Ok(Some(_)))
392 }
393
394 async fn reopen(&mut self) -> Result<()> {
404 let mut launch = self.prefix.clone();
405 launch
406 .arguments
407 .extend(["--resume".into(), self.handle.runtime_id.clone()]);
408 let transport = RawLineTransport::spawn(&launch, Some(&self.cwd), "claude-stream-json")
409 .await
410 .map_err(|error| {
411 Error::Other(format!(
412 "could not resume Claude Code session `{}` after its process exited: {error}",
413 self.handle.runtime_id
414 ))
415 })?;
416 self.handle.endpoint = transport.endpoint.clone();
417 self.transport = transport;
418 self.pending_permissions.clear();
421 self.spoke = false;
422 Ok(())
423 }
424
425 async fn write_turn(&mut self, frame: Value) -> Result<()> {
433 if self.process_ended().await {
434 self.reopen().await?;
435 }
436 match self.transport.write(frame.clone()).await {
437 Ok(()) => Ok(()),
438 Err(error) if broken_pipe(&error) => {
439 self.reopen().await?;
440 self.transport.write(frame).await
441 }
442 Err(error) => Err(error),
443 }
444 }
445
446 fn is_control_response(value: &Value) -> bool {
451 value.get("type").and_then(Value::as_str) == Some("control_response")
452 }
453
454 fn control_result(value: &Value, request_id: &str) -> Option<Result<()>> {
462 let response = value.get("response")?;
463 if response.get("request_id").and_then(Value::as_str) != Some(request_id) {
464 return None;
465 }
466 match response.get("subtype").and_then(Value::as_str) {
467 Some("success") => Some(Ok(())),
468 other => Some(Err(Error::Other(format!(
469 "Claude Code rejected the interrupt control request: {}",
470 response
471 .get("error")
472 .and_then(Value::as_str)
473 .map(str::to_string)
474 .unwrap_or_else(|| format!(
475 "control_response subtype {}",
476 other.unwrap_or("(missing)")
477 ))
478 )))),
479 }
480 }
481
482 fn permission_request_id(value: &Value) -> Option<&str> {
489 if value.get("type").and_then(Value::as_str)? != "control_request" {
490 return None;
491 }
492 let request = value.get("request")?;
493 if request.get("subtype").and_then(Value::as_str)? != "can_use_tool" {
494 return None;
495 }
496 value.get("request_id").and_then(Value::as_str)
497 }
498
499 fn note_permission_request(&mut self, payload: &Value) {
501 let Some(request_id) = Self::permission_request_id(payload) else {
502 return;
503 };
504 if self
505 .pending_permissions
506 .iter()
507 .any(|pending| pending.request_id == request_id)
508 {
509 return;
510 }
511 self.pending_permissions.push(PendingPermission {
512 request_id: request_id.to_string(),
513 deadline: tokio::time::Instant::now() + self.permission_timeout,
514 });
515 }
516
517 async fn write_permission_response(&mut self, request_id: &str, body: Value) -> Result<()> {
519 self.transport
520 .write(json!({
521 "type": "control_response",
522 "response": {
523 "subtype": "success",
524 "request_id": request_id,
525 "response": body,
526 },
527 }))
528 .await
529 }
530
531 async fn deny_expired_permissions(&mut self) -> Result<()> {
536 let now = tokio::time::Instant::now();
537 let expired = self
538 .pending_permissions
539 .iter()
540 .filter(|pending| pending.deadline <= now)
541 .map(|pending| pending.request_id.clone())
542 .collect::<Vec<_>>();
543 self.pending_permissions
544 .retain(|pending| pending.deadline > now);
545 for request_id in expired {
546 self.write_permission_response(
547 &request_id,
548 json!({"behavior": "deny", "message": CLAUDE_PERMISSION_TIMEOUT_MESSAGE}),
549 )
550 .await?;
551 }
552 Ok(())
553 }
554
555 async fn transport_ended(&mut self) -> Result<Option<HarnessEvent>> {
572 if !self.spoke {
573 return Ok(None);
574 }
575 std::future::pending().await
576 }
577
578 fn next_permission_deadline(&self) -> Option<Duration> {
580 let now = tokio::time::Instant::now();
581 self.pending_permissions
582 .iter()
583 .map(|pending| pending.deadline.saturating_duration_since(now))
584 .min()
585 }
586}
587
588fn claude_permission_result(response: Value) -> Result<Value> {
598 let Value::Object(mut body) = response else {
599 return Err(claude_permission_shape_error(&response));
600 };
601 match body.get("behavior").and_then(Value::as_str) {
602 Some("allow") => {}
603 Some("deny") => {
604 let empty = body
606 .get("message")
607 .and_then(Value::as_str)
608 .is_none_or(str::is_empty);
609 if empty {
610 body.insert(
611 "message".into(),
612 Value::String("supercode denied this permission request".into()),
613 );
614 }
615 }
616 _ => return Err(claude_permission_shape_error(&Value::Object(body))),
617 }
618 Ok(Value::Object(body))
619}
620
621fn claude_permission_shape_error(response: &Value) -> Error {
622 Error::Other(format!(
623 "Claude Code permission answers must carry a `behavior` of {}; got {response}",
624 CLAUDE_PERMISSION_BEHAVIORS
625 .map(|behavior| format!("`{behavior}`"))
626 .join(" or "),
627 ))
628}
629
630#[async_trait]
631impl RuntimeConnection for ClaudeRuntimeConnection {
632 fn handle(&self) -> &RuntimeHandle {
633 &self.handle
634 }
635
636 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
637 let content = if input.image_urls.is_empty() {
638 Value::String(input.text)
639 } else {
640 let mut parts = Vec::new();
641 if !input.text.is_empty() {
642 parts.push(json!({"type":"text", "text":input.text}));
643 }
644 for url in input.image_urls {
645 parts.push(claude_image_part(&url)?);
646 }
647 Value::Array(parts)
648 };
649 self.write_turn(json!({
650 "type": "user",
651 "session_id": self.handle.runtime_id,
652 "message": {"role": "user", "content": content},
653 }))
654 .await?;
655 Ok(None)
656 }
657
658 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
659 if let Some(payload) = self.buffered_events.pop_front() {
660 self.spoke = true;
661 return Ok(Some(harness_event(payload)));
662 }
663 loop {
664 self.deny_expired_permissions().await?;
668 let payload = match self.next_permission_deadline() {
669 Some(remaining) => {
670 match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
671 Err(_) => continue,
672 Ok(None) => return self.transport_ended().await,
673 Ok(Some(payload)) => payload,
674 }
675 }
676 None => match self.transport.receiver.recv().await {
677 None => return self.transport_ended().await,
678 Some(payload) => payload,
679 },
680 };
681 self.spoke = true;
682 if Self::is_control_response(&payload) {
683 continue;
684 }
685 self.note_permission_request(&payload);
686 return Ok(Some(harness_event(payload)));
687 }
688 }
689
690 async fn interrupt(&mut self) -> Result<()> {
699 if self.process_ended().await {
703 return Ok(());
704 }
705 let request_id = format!(
706 "supercode-{}-interrupt-{}",
707 self.handle.runtime_id, self.next_control_request
708 );
709 self.next_control_request += 1;
710 self.transport
711 .write(json!({
712 "type": "control_request",
713 "request_id": request_id,
714 "request": {"subtype": "interrupt"},
715 }))
716 .await?;
717
718 let deadline = tokio::time::Instant::now() + self.control_timeout;
719 loop {
720 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
721 if remaining.is_zero() {
722 return Err(claude_interrupt_timeout(self.control_timeout));
723 }
724 match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
725 Err(_) => return Err(claude_interrupt_timeout(self.control_timeout)),
726 Ok(None) => return Err(Error::Other(
727 "Claude Code stream-json transport closed before acknowledging the interrupt"
728 .into(),
729 )),
730 Ok(Some(payload)) => {
731 if Self::is_control_response(&payload) {
732 if let Some(result) = Self::control_result(&payload, &request_id) {
733 return result;
734 }
735 continue;
736 }
737 self.note_permission_request(&payload);
741 self.buffered_events.push_back(payload);
742 }
743 }
744 }
745 }
746
747 async fn steer(&mut self, text: String) -> Result<()> {
748 self.send_input(RuntimeInput {
749 text,
750 image_urls: Vec::new(),
751 })
752 .await
753 .map(|_| ())
754 }
755
756 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
765 let Some(request_id) = request_id.as_str().map(str::to_string) else {
766 return Err(Error::Other(format!(
767 "Claude Code control requests are identified by a string `request_id`; got \
768 {request_id}"
769 )));
770 };
771 let Some(index) = self
772 .pending_permissions
773 .iter()
774 .position(|pending| pending.request_id == request_id)
775 else {
776 return Err(Error::Other(format!(
777 "no Claude Code permission request `{request_id}` is waiting on this connection — \
778 a `can_use_tool` request is answerable only while its turn is blocked on it, and \
779 only until it is answered or denied on timeout"
780 )));
781 };
782 let body = claude_permission_result(response)?;
783 self.pending_permissions.remove(index);
784 self.write_permission_response(&request_id, body).await
785 }
786
787 async fn close(&mut self) -> Result<()> {
788 self.transport.close().await
789 }
790}
791
792#[derive(Debug, Clone)]
794pub struct AcpRuntimeBackend {
795 harness: HarnessId,
796 launch: RuntimeLaunch,
797 resume_session: bool,
798}
799
800impl AcpRuntimeBackend {
801 pub fn new(harness: HarnessId, launch: RuntimeLaunch) -> Self {
803 Self {
804 harness,
805 launch,
806 resume_session: false,
807 }
808 }
809
810 pub fn with_resume_support(mut self, supported: bool) -> Self {
814 self.resume_session = supported;
815 self
816 }
817
818 async fn connect(
819 &self,
820 cwd: &Path,
821 launch: Option<RuntimeLaunch>,
822 ) -> Result<(
823 Arc<JsonLineClient>,
824 mpsc::UnboundedReceiver<Value>,
825 RuntimeEndpoint,
826 Value,
827 )> {
828 let launch = launch.unwrap_or_else(|| self.launch.clone());
829 let (client, receiver, endpoint) =
830 JsonLineClient::spawn(&launch, Some(cwd), true, "acp-v1-jsonrpc").await?;
831 let initialized = client
832 .request(
833 "initialize",
834 json!({
835 "protocolVersion": 1,
836 "clientCapabilities": {},
837 "clientInfo": {
838 "name": "supercode",
839 "title": "Supercode",
840 "version": env!("CARGO_PKG_VERSION"),
841 },
842 }),
843 )
844 .await?;
845 if initialized.get("protocolVersion").and_then(Value::as_u64) != Some(1) {
846 return Err(Error::Other(format!(
847 "ACP agent negotiated unsupported protocol version: {}",
848 initialized
849 .get("protocolVersion")
850 .cloned()
851 .unwrap_or(Value::Null)
852 )));
853 }
854 Ok((client, receiver, endpoint, initialized))
855 }
856
857 async fn session_request(
858 &self,
859 client: &JsonLineClient,
860 initialized: &Value,
861 method: &str,
862 params: Value,
863 ) -> Result<Value> {
864 match client.request(method, params.clone()).await {
865 Ok(response) => Ok(response),
866 Err(error) if acp_auth_required(&error.to_string()) => {
867 let cached = initialized
868 .get("authMethods")
869 .and_then(Value::as_array)
870 .and_then(|methods| {
871 methods.iter().find_map(|candidate| {
872 (candidate.get("id").and_then(Value::as_str) == Some("cached_token"))
873 .then_some("cached_token")
874 })
875 });
876 let Some(method_id) = cached else {
877 return Err(Error::Other(
878 "ACP agent requires authentication but did not advertise the non-interactive `cached_token` method"
879 .into(),
880 ));
881 };
882 client
883 .request(
884 "authenticate",
885 json!({"methodId": method_id, "_meta": {"headless": true}}),
886 )
887 .await?;
888 client.request(method, params).await
889 }
890 Err(error) => Err(error),
891 }
892 }
893
894 async fn connection(
895 &self,
896 cwd: &Path,
897 runtime_id: Option<String>,
898 launch: Option<RuntimeLaunch>,
899 mcp_servers: Vec<McpServerLaunch>,
900 ) -> Result<Box<dyn RuntimeConnection>> {
901 let mcp_servers = acp_mcp_servers(&mcp_servers);
902 let (client, mut receiver, endpoint, initialized) = self.connect(cwd, launch).await?;
903 let session_id = if let Some(session_id) = runtime_id {
904 let resume = initialized
905 .pointer("/agentCapabilities/sessionCapabilities/resume")
906 .is_some();
907 let load = initialized
908 .pointer("/agentCapabilities/loadSession")
909 .and_then(Value::as_bool)
910 .unwrap_or(false);
911 let method = if resume {
912 "session/resume"
913 } else if load {
914 "session/load"
915 } else {
916 return Err(Error::Other(
917 "ACP agent did not advertise session resume or load".into(),
918 ));
919 };
920 self.session_request(
921 client.as_ref(),
922 &initialized,
923 method,
924 json!({"sessionId": session_id, "cwd": cwd, "mcpServers": mcp_servers}),
925 )
926 .await?;
927 session_id
928 } else {
929 self.session_request(
930 client.as_ref(),
931 &initialized,
932 "session/new",
933 json!({"cwd": cwd, "mcpServers": mcp_servers}),
934 )
935 .await?
936 .get("sessionId")
937 .and_then(Value::as_str)
938 .ok_or_else(|| Error::Other("ACP session/new omitted sessionId".into()))?
939 .to_string()
940 };
941 while receiver.try_recv().is_ok() {}
950 Ok(Box::new(AcpRuntimeConnection {
951 handle: RuntimeHandle {
952 harness: self.harness.clone(),
953 runtime_id: session_id,
954 endpoint,
955 },
956 client,
957 receiver,
958 active_prompt: None,
959 }))
960 }
961}
962
963fn acp_mcp_servers(servers: &[McpServerLaunch]) -> Value {
968 Value::Array(
969 servers
970 .iter()
971 .map(|server| {
972 json!({
973 "name": server.name,
974 "command": server.command,
975 "args": server.arguments,
976 "env": server
977 .env
978 .iter()
979 .map(|(name, value)| json!({"name": name, "value": value}))
980 .collect::<Vec<_>>(),
981 })
982 })
983 .collect::<Vec<_>>(),
984 )
985}
986
987fn acp_auth_required(message: &str) -> bool {
988 let message = message.to_ascii_lowercase();
989 [
990 "auth",
991 "login",
992 "sign in",
993 "sign-in",
994 "unauthorized",
995 "forbidden",
996 "credential",
997 ]
998 .iter()
999 .any(|needle| message.contains(needle))
1000}
1001
1002#[async_trait]
1003impl RuntimeBackend for AcpRuntimeBackend {
1004 fn harness(&self) -> HarnessId {
1005 self.harness.clone()
1006 }
1007
1008 fn capabilities(&self) -> RuntimeCapabilities {
1009 RuntimeCapabilities {
1010 start_session: true,
1011 resume_session: self.resume_session,
1014 attach_existing_process: false,
1015 send_input: true,
1016 stream_events: true,
1017 interrupt: true,
1018 steer: false,
1019 respond_to_requests: true,
1020 }
1021 }
1022
1023 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
1024 self.connection(&request.cwd, None, request.launch, request.mcp_servers)
1025 .await
1026 }
1027
1028 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
1029 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1030 self.connection(&cwd, Some(request.runtime_id), request.launch, Vec::new())
1031 .await
1032 }
1033}
1034
1035struct AcpRuntimeConnection {
1036 handle: RuntimeHandle,
1037 client: Arc<JsonLineClient>,
1038 receiver: mpsc::UnboundedReceiver<Value>,
1039 active_prompt: Option<u64>,
1040}
1041
1042#[async_trait]
1043impl RuntimeConnection for AcpRuntimeConnection {
1044 fn handle(&self) -> &RuntimeHandle {
1045 &self.handle
1046 }
1047
1048 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
1049 let mut prompt = Vec::new();
1050 if !input.text.is_empty() {
1051 prompt.push(json!({"type": "text", "text": input.text}));
1052 }
1053 for url in input.image_urls {
1054 let (mime_type, data) = data_image_parts(&url).ok_or_else(|| {
1055 Error::Other("ACP image prompts require base64 image data URLs".into())
1056 })?;
1057 prompt.push(json!({"type":"image", "mimeType":mime_type, "data":data}));
1058 }
1059 let (id, response) = self
1060 .client
1061 .begin_request(
1062 "session/prompt",
1063 json!({
1064 "sessionId": self.handle.runtime_id,
1065 "prompt": prompt,
1066 }),
1067 )
1068 .await?;
1069 self.active_prompt = Some(id);
1070 let client = self.client.clone();
1071 tokio::spawn(async move {
1072 let result = match response.await {
1073 Ok(Ok(result)) => json!({"id": id, "result": result}),
1074 Ok(Err(error)) => json!({"id": id, "error": error}),
1075 Err(_) => json!({"id": id, "error": "response channel closed"}),
1076 };
1077 client.emit(json!({
1078 "jsonrpc": "2.0",
1079 "method": "supercode/acp_request_completed",
1080 "params": result,
1081 }));
1082 });
1083 Ok(Some(id.to_string()))
1084 }
1085
1086 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1087 let Some(payload) = self.receiver.recv().await else {
1088 return Ok(None);
1089 };
1090 let kind = payload
1091 .get("method")
1092 .and_then(Value::as_str)
1093 .or_else(|| payload.get("type").and_then(Value::as_str))
1094 .unwrap_or("protocol")
1095 .to_string();
1096 if kind == "supercode/acp_request_completed" {
1097 self.active_prompt = None;
1098 }
1099 Ok(Some(HarnessEvent {
1100 sequence: None,
1101 kind,
1102 payload,
1103 }))
1104 }
1105
1106 async fn interrupt(&mut self) -> Result<()> {
1107 self.client
1108 .notify(
1109 "session/cancel",
1110 json!({"sessionId": self.handle.runtime_id}),
1111 )
1112 .await
1113 }
1114
1115 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1116 self.client.respond(request_id, response).await
1117 }
1118
1119 async fn close(&mut self) -> Result<()> {
1120 self.client.close().await
1121 }
1122}
1123
1124#[derive(Debug, Clone)]
1128pub struct OpenCodeRuntimeBackend {
1129 launch: RuntimeLaunch,
1130 base_url: Option<String>,
1131 bearer: Option<BearerToken>,
1132}
1133
1134impl Default for OpenCodeRuntimeBackend {
1135 fn default() -> Self {
1136 Self::new()
1137 }
1138}
1139
1140impl OpenCodeRuntimeBackend {
1141 pub fn new() -> Self {
1143 Self {
1144 launch: RuntimeLaunch {
1145 program: "opencode".into(),
1146 arguments: vec!["serve".into()],
1147 env: BTreeMap::new(),
1148 },
1149 base_url: None,
1150 bearer: None,
1151 }
1152 }
1153
1154 pub fn connect(base_url: impl Into<String>) -> Self {
1157 Self {
1158 base_url: Some(base_url.into().trim_end_matches('/').to_string()),
1159 ..Self::new()
1160 }
1161 }
1162
1163 pub fn with_launch(mut self, launch: RuntimeLaunch) -> Self {
1165 self.launch = launch;
1166 self
1167 }
1168
1169 pub fn with_bearer(mut self, token: BearerToken) -> Self {
1172 self.bearer = Some(token);
1173 self
1174 }
1175
1176 fn http_client(&self) -> Result<reqwest::Client> {
1177 let Some(token) = &self.bearer else {
1178 return Ok(reqwest::Client::new());
1179 };
1180 let mut headers = reqwest::header::HeaderMap::new();
1181 let mut value =
1182 reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token.secret())).map_err(
1183 |_| Error::Other("connect-mode bearer token is not a valid header value".into()),
1184 )?;
1185 value.set_sensitive(true);
1186 headers.insert(reqwest::header::AUTHORIZATION, value);
1187 reqwest::Client::builder()
1188 .default_headers(headers)
1189 .build()
1190 .map_err(|error| Error::Other(format!("could not build HTTP client: {error}")))
1191 }
1192
1193 async fn service(
1194 &self,
1195 client: &reqwest::Client,
1196 launch: Option<RuntimeLaunch>,
1197 ) -> Result<(String, Option<super::GroupLeader>)> {
1198 if let Some(base_url) = &self.base_url {
1199 wait_for_health(client, base_url).await?;
1200 return Ok((base_url.clone(), None));
1201 }
1202 let port = TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port();
1203 let mut launch = launch.unwrap_or_else(|| self.launch.clone());
1204 launch.arguments.extend([
1205 "--hostname".into(),
1206 "127.0.0.1".into(),
1207 "--port".into(),
1208 port.to_string(),
1209 ]);
1210 let mut command = Command::new(&launch.program);
1211 command
1212 .args(&launch.arguments)
1213 .envs(&launch.env)
1214 .stdin(Stdio::null())
1215 .stdout(Stdio::null())
1216 .stderr(Stdio::inherit())
1217 .kill_on_drop(true);
1218 #[cfg(unix)]
1222 command.process_group(0);
1223 let mut child = super::GroupLeader(command.spawn().map_err(|error| {
1228 Error::Other(format!("could not launch {}: {error}", launch.program))
1229 })?);
1230 let base_url = format!("http://127.0.0.1:{port}");
1231 if let Err(error) = wait_for_health(client, &base_url).await {
1232 let _ = terminate_opencode_server(&mut child).await;
1233 return Err(error);
1234 }
1235 Ok((base_url, Some(child)))
1236 }
1237
1238 async fn open(
1239 &self,
1240 cwd: &Path,
1241 runtime_id: Option<String>,
1242 launch: Option<RuntimeLaunch>,
1243 ) -> Result<Box<dyn RuntimeConnection>> {
1244 let client = self.http_client()?;
1245 let (base_url, child) = self.service(&client, launch).await?;
1246 let cwd_string = cwd.to_string_lossy().to_string();
1247 let runtime_id = match runtime_id {
1248 Some(id) => {
1249 http_ok(
1250 client
1251 .get(format!("{base_url}/session/{id}"))
1252 .query(&[("directory", &cwd_string)])
1253 .send()
1254 .await,
1255 )
1256 .await?;
1257 id
1258 }
1259 None => {
1260 let response = http_ok(
1261 client
1262 .post(format!("{base_url}/session"))
1263 .query(&[("directory", &cwd_string)])
1264 .json(&json!({}))
1265 .send()
1266 .await,
1267 )
1268 .await?;
1269 response
1270 .json::<Value>()
1271 .await
1272 .map_err(http_error)?
1273 .get("id")
1274 .and_then(Value::as_str)
1275 .ok_or_else(|| Error::Other("OpenCode create session omitted id".into()))?
1276 .to_string()
1277 }
1278 };
1279 let receiver = spawn_sse(
1280 client.clone(),
1281 format!("{base_url}/event"),
1282 cwd_string.clone(),
1283 );
1284 Ok(Box::new(OpenCodeRuntimeConnection {
1285 handle: RuntimeHandle {
1286 harness: HarnessId::from(HarnessId::OPENCODE),
1287 runtime_id,
1288 endpoint: RuntimeEndpoint::Http {
1289 base_url: base_url.clone(),
1290 protocol: "opencode-http-sse".into(),
1291 },
1292 },
1293 base_url,
1294 cwd: cwd_string,
1295 client,
1296 receiver,
1297 child,
1298 }))
1299 }
1300}
1301
1302#[async_trait]
1303impl RuntimeBackend for OpenCodeRuntimeBackend {
1304 fn harness(&self) -> HarnessId {
1305 HarnessId::from(HarnessId::OPENCODE)
1306 }
1307
1308 fn capabilities(&self) -> RuntimeCapabilities {
1309 RuntimeCapabilities {
1310 start_session: true,
1311 resume_session: true,
1312 attach_existing_process: self.base_url.is_some(),
1313 send_input: true,
1314 stream_events: true,
1315 interrupt: true,
1316 steer: false,
1317 respond_to_requests: true,
1318 }
1319 }
1320
1321 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
1322 self.open(&request.cwd, None, request.launch).await
1323 }
1324
1325 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
1326 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1327 self.open(&cwd, Some(request.runtime_id), request.launch)
1328 .await
1329 }
1330
1331 async fn attach_existing(
1332 &self,
1333 request: RuntimeAttachRequest,
1334 ) -> Result<Box<dyn RuntimeConnection>> {
1335 if self.base_url.is_none() {
1336 return Err(Error::Other(
1337 "OpenCode live attach requires the existing server's `base_url`".into(),
1338 ));
1339 }
1340 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1341 self.open(&cwd, Some(request.runtime_id), request.launch)
1342 .await
1343 }
1344}
1345
1346struct OpenCodeRuntimeConnection {
1347 handle: RuntimeHandle,
1348 base_url: String,
1349 cwd: String,
1350 client: reqwest::Client,
1351 receiver: mpsc::UnboundedReceiver<Value>,
1352 child: Option<super::GroupLeader>,
1353}
1354
1355#[async_trait]
1356impl RuntimeConnection for OpenCodeRuntimeConnection {
1357 fn handle(&self) -> &RuntimeHandle {
1358 &self.handle
1359 }
1360
1361 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
1362 let mut parts = Vec::new();
1363 if !input.text.is_empty() {
1364 parts.push(json!({"type": "text", "text": input.text}));
1365 }
1366 for url in input.image_urls {
1367 let mime = image_mime_type(&url).ok_or_else(|| {
1368 Error::Other("OpenCode image prompts require a recognizable image MIME type".into())
1369 })?;
1370 parts.push(json!({"type":"file", "mime":mime, "url":url}));
1371 }
1372 http_ok(
1373 self.client
1374 .post(format!(
1375 "{}/session/{}/prompt_async",
1376 self.base_url, self.handle.runtime_id
1377 ))
1378 .query(&[("directory", &self.cwd)])
1379 .json(&json!({"parts": parts}))
1380 .send()
1381 .await,
1382 )
1383 .await?;
1384 Ok(None)
1385 }
1386
1387 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1388 loop {
1389 let Some(payload) = self.receiver.recv().await else {
1390 return Ok(None);
1391 };
1392 if opencode_event_session_id(&payload)
1393 .is_some_and(|session_id| session_id != self.handle.runtime_id)
1394 {
1395 continue;
1396 }
1397 let kind = payload
1398 .get("type")
1399 .and_then(Value::as_str)
1400 .unwrap_or("event")
1401 .to_string();
1402 return Ok(Some(HarnessEvent {
1403 sequence: None,
1404 kind,
1405 payload,
1406 }));
1407 }
1408 }
1409
1410 async fn interrupt(&mut self) -> Result<()> {
1411 http_ok(
1412 self.client
1413 .post(format!(
1414 "{}/session/{}/abort",
1415 self.base_url, self.handle.runtime_id
1416 ))
1417 .query(&[("directory", &self.cwd)])
1418 .send()
1419 .await,
1420 )
1421 .await?;
1422 Ok(())
1423 }
1424
1425 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1426 let permission = request_id.as_str().ok_or_else(|| {
1427 Error::Other("OpenCode permission request id must be a string".into())
1428 })?;
1429 http_ok(
1430 self.client
1431 .post(format!(
1432 "{}/session/{}/permissions/{permission}",
1433 self.base_url, self.handle.runtime_id
1434 ))
1435 .query(&[("directory", &self.cwd)])
1436 .json(&response)
1437 .send()
1438 .await,
1439 )
1440 .await?;
1441 Ok(())
1442 }
1443
1444 async fn close(&mut self) -> Result<()> {
1445 if let Some(child) = &mut self.child {
1446 terminate_opencode_server(child).await?;
1447 }
1448 Ok(())
1449 }
1450}
1451
1452fn data_image_parts(url: &str) -> Option<(&str, &str)> {
1453 let rest = url.strip_prefix("data:")?;
1454 let (mime_type, data) = rest.split_once(";base64,")?;
1455 mime_type.starts_with("image/").then_some((mime_type, data))
1456}
1457
1458fn image_mime_type(url: &str) -> Option<&str> {
1459 if let Some((mime_type, _)) = data_image_parts(url) {
1460 return Some(mime_type);
1461 }
1462 let path = url.split(['?', '#']).next()?.to_ascii_lowercase();
1463 if path.ends_with(".png") {
1464 Some("image/png")
1465 } else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
1466 Some("image/jpeg")
1467 } else if path.ends_with(".gif") {
1468 Some("image/gif")
1469 } else if path.ends_with(".webp") {
1470 Some("image/webp")
1471 } else {
1472 None
1473 }
1474}
1475
1476fn claude_image_part(url: &str) -> Result<Value> {
1477 if let Some((media_type, data)) = data_image_parts(url) {
1478 return Ok(json!({
1479 "type":"image",
1480 "source":{"type":"base64", "media_type":media_type, "data":data}
1481 }));
1482 }
1483 if url.starts_with("https://") || url.starts_with("http://") {
1484 return Ok(json!({"type":"image", "source":{"type":"url", "url":url}}));
1485 }
1486 Err(Error::Other(
1487 "Claude image prompts require image data URLs or HTTP(S) URLs".into(),
1488 ))
1489}
1490
1491fn opencode_event_session_id(payload: &Value) -> Option<&str> {
1492 let properties = payload.get("properties").unwrap_or(payload);
1493 properties
1494 .get("sessionID")
1495 .and_then(Value::as_str)
1496 .or_else(|| {
1497 properties
1498 .get("part")
1499 .and_then(|part| part.get("sessionID"))
1500 .and_then(Value::as_str)
1501 })
1502 .or_else(|| {
1503 properties
1504 .get("info")
1505 .and_then(|info| info.get("sessionID"))
1506 .and_then(Value::as_str)
1507 })
1508}
1509
1510async fn terminate_opencode_server(child: &mut Child) -> Result<()> {
1511 #[cfg(unix)]
1512 let process_group = child.id();
1513 let leader_exited = child.try_wait()?.is_some();
1514 if leader_exited {
1515 #[cfg(unix)]
1516 if let Some(pid) = process_group.filter(|pid| process_group_exists(*pid)) {
1517 crate::lsp::kill_process_group(pid);
1518 wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1519 }
1520 return Ok(());
1521 }
1522 #[cfg(unix)]
1528 if let Some(pid) = process_group {
1529 unsafe {
1530 libc::kill(-(pid as libc::pid_t), libc::SIGTERM);
1531 }
1532 let mut leader_reaped = false;
1533 if let Ok(status) = tokio::time::timeout(Duration::from_millis(500), child.wait()).await {
1534 status?;
1535 leader_reaped = true;
1536 if !process_group_exists(pid) {
1537 return Ok(());
1538 }
1539 }
1540 crate::lsp::kill_process_group(pid);
1543 if leader_reaped {
1544 return wait_for_process_group_exit(pid, Duration::from_secs(3)).await;
1545 }
1546 }
1547 #[cfg(not(unix))]
1548 child.start_kill()?;
1549 tokio::time::timeout(Duration::from_secs(3), child.wait())
1550 .await
1551 .map_err(|_| Error::Other("timed out reaping the OpenCode server".into()))??;
1552 #[cfg(unix)]
1553 if let Some(pid) = process_group {
1554 wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1555 }
1556 Ok(())
1557}
1558
1559#[cfg(unix)]
1560fn process_group_exists(pid: u32) -> bool {
1561 let result = unsafe { libc::kill(-(pid as libc::pid_t), 0) };
1562 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1563}
1564
1565#[cfg(unix)]
1566async fn wait_for_process_group_exit(pid: u32, timeout: Duration) -> Result<()> {
1567 let deadline = tokio::time::Instant::now() + timeout;
1568 while process_group_exists(pid) {
1569 if tokio::time::Instant::now() >= deadline {
1570 return Err(Error::Other(format!(
1571 "timed out stopping OpenCode process group {pid}"
1572 )));
1573 }
1574 tokio::time::sleep(Duration::from_millis(10)).await;
1575 }
1576 Ok(())
1577}
1578
1579struct RawLineTransport {
1580 stdin: Mutex<ChildStdin>,
1581 child: Mutex<super::GroupLeader>,
1582 receiver: mpsc::UnboundedReceiver<Value>,
1583 endpoint: RuntimeEndpoint,
1584}
1585
1586impl RawLineTransport {
1587 async fn spawn(launch: &RuntimeLaunch, cwd: Option<&Path>, protocol: &str) -> Result<Self> {
1588 let mut command = Command::new(&launch.program);
1589 command
1590 .args(&launch.arguments)
1591 .envs(&launch.env)
1592 .stdin(Stdio::piped())
1593 .stdout(Stdio::piped())
1594 .stderr(Stdio::inherit())
1595 .kill_on_drop(true);
1596 #[cfg(unix)]
1600 command.process_group(0);
1601 if let Some(cwd) = cwd {
1602 command.current_dir(cwd);
1603 }
1604 let mut child = command.spawn().map_err(|error| {
1605 Error::Other(format!("could not launch {}: {error}", launch.program))
1606 })?;
1607 let pid = child.id();
1608 let stdin = child
1609 .stdin
1610 .take()
1611 .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
1612 let stdout = child
1613 .stdout
1614 .take()
1615 .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
1616 let (sender, receiver) = mpsc::unbounded_channel();
1617 tokio::spawn(async move {
1618 let mut lines = BufReader::new(stdout).lines();
1619 while let Ok(Some(line)) = lines.next_line().await {
1620 let value = serde_json::from_str(&line)
1621 .unwrap_or_else(|_| json!({"type": "malformed_output", "line": line}));
1622 let _ = sender.send(value);
1623 }
1624 });
1625 Ok(Self {
1626 stdin: Mutex::new(stdin),
1627 child: Mutex::new(super::GroupLeader(child)),
1628 receiver,
1629 endpoint: RuntimeEndpoint::LocalProcess {
1630 pid,
1631 command: std::iter::once(launch.program.clone())
1632 .chain(launch.arguments.iter().cloned())
1633 .collect(),
1634 protocol: protocol.into(),
1635 },
1636 })
1637 }
1638
1639 async fn write(&self, value: Value) -> Result<()> {
1640 let mut stdin = self.stdin.lock().await;
1641 stdin.write_all(value.to_string().as_bytes()).await?;
1642 stdin.write_all(b"\n").await?;
1643 stdin.flush().await?;
1644 Ok(())
1645 }
1646
1647 async fn close(&self) -> Result<()> {
1648 let mut child = self.child.lock().await;
1649 if child.try_wait()?.is_some() {
1650 return Ok(());
1651 }
1652 #[cfg(unix)]
1655 if let Some(pid) = child.id() {
1656 crate::lsp::kill_process_group(pid);
1657 tokio::time::timeout(Duration::from_secs(3), child.wait())
1658 .await
1659 .map_err(|_| Error::Other("timed out reaping runtime process group".into()))??;
1660 return Ok(());
1661 }
1662 #[cfg(not(unix))]
1663 child.kill().await?;
1664 Ok(())
1665 }
1666}
1667
1668async fn raw_next_event(
1669 receiver: &mut mpsc::UnboundedReceiver<Value>,
1670) -> Result<Option<HarnessEvent>> {
1671 let Some(payload) = receiver.recv().await else {
1672 return Ok(None);
1673 };
1674 Ok(Some(harness_event(payload)))
1675}
1676
1677fn harness_event(payload: Value) -> HarnessEvent {
1678 let kind = payload
1679 .get("type")
1680 .and_then(Value::as_str)
1681 .unwrap_or("event")
1682 .to_string();
1683 HarnessEvent {
1684 sequence: None,
1685 kind,
1686 payload,
1687 }
1688}
1689
1690fn broken_pipe(error: &Error) -> bool {
1697 matches!(error, Error::Io(io) if io.kind() == std::io::ErrorKind::BrokenPipe)
1698}
1699
1700fn claude_interrupt_timeout(bound: Duration) -> Error {
1701 Error::Other(format!(
1702 "Claude Code did not acknowledge the interrupt control request within {}s",
1703 bound.as_secs_f32()
1704 ))
1705}
1706
1707pub(crate) fn generated_session_id() -> String {
1708 let mut bytes = [0_u8; 16];
1709 if getrandom::getrandom(&mut bytes).is_err() {
1710 let nanos = SystemTime::now()
1711 .duration_since(UNIX_EPOCH)
1712 .unwrap_or_default()
1713 .as_nanos()
1714 .to_le_bytes();
1715 bytes.copy_from_slice(&nanos);
1716 }
1717 bytes[6] = (bytes[6] & 0x0f) | 0x40;
1718 bytes[8] = (bytes[8] & 0x3f) | 0x80;
1719 format!(
1720 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
1721 bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
1722 bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
1723 )
1724}
1725
1726async fn wait_for_health(client: &reqwest::Client, base_url: &str) -> Result<()> {
1727 wait_for_health_for(client, base_url, Duration::from_secs(10)).await
1728}
1729
1730async fn wait_for_health_for(
1731 client: &reqwest::Client,
1732 base_url: &str,
1733 total_timeout: Duration,
1734) -> Result<()> {
1735 let url = format!("{base_url}/global/health");
1736 let mut last = None;
1737 let deadline = tokio::time::Instant::now() + total_timeout;
1738 loop {
1743 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1744 if remaining.is_zero() {
1745 break;
1746 }
1747 let request_timeout = remaining.min(Duration::from_millis(500));
1748 match tokio::time::timeout(request_timeout, client.get(&url).send()).await {
1749 Ok(Ok(response)) if response.status().is_success() => return Ok(()),
1750 Ok(Ok(response)) => last = Some(format!("HTTP {}", response.status())),
1751 Ok(Err(error)) => last = Some(error.to_string()),
1752 Err(_) => last = Some("health request timed out".into()),
1753 }
1754 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1755 if !remaining.is_zero() {
1756 tokio::time::sleep(remaining.min(Duration::from_millis(100))).await;
1757 }
1758 }
1759 Err(Error::Other(format!(
1760 "OpenCode server at {base_url} did not become healthy: {}",
1761 last.unwrap_or_else(|| "no response".into())
1762 )))
1763}
1764
1765async fn http_ok(
1766 response: std::result::Result<reqwest::Response, reqwest::Error>,
1767) -> Result<reqwest::Response> {
1768 response
1769 .map_err(http_error)?
1770 .error_for_status()
1771 .map_err(http_error)
1772}
1773
1774fn http_error(error: reqwest::Error) -> Error {
1775 Error::Other(format!("runtime HTTP request failed: {error}"))
1776}
1777
1778fn spawn_sse(
1779 client: reqwest::Client,
1780 url: String,
1781 directory: String,
1782) -> mpsc::UnboundedReceiver<Value> {
1783 let (sender, receiver) = mpsc::unbounded_channel();
1784 tokio::spawn(async move {
1785 let response = client
1786 .get(url)
1787 .query(&[("directory", directory)])
1788 .send()
1789 .await;
1790 let Ok(response) = response.and_then(reqwest::Response::error_for_status) else {
1791 let _ = sender.send(
1792 json!({"type": "stream_error", "message": "could not open OpenCode SSE stream"}),
1793 );
1794 return;
1795 };
1796 let mut stream = response.bytes_stream();
1797 let mut buffer = String::new();
1798 while let Some(chunk) = stream.next().await {
1799 let Ok(chunk) = chunk else {
1800 break;
1801 };
1802 buffer.push_str(&String::from_utf8_lossy(&chunk));
1803 while let Some(newline) = buffer.find('\n') {
1804 let line = buffer[..newline].trim_end_matches('\r').to_string();
1805 buffer.drain(..=newline);
1806 if let Some(data) = line.strip_prefix("data:") {
1807 let data = data.trim();
1808 if let Ok(value) = serde_json::from_str(data) {
1809 let _ = sender.send(value);
1810 }
1811 }
1812 }
1813 }
1814 });
1815 receiver
1816}
1817
1818#[cfg(test)]
1819mod tests {
1820 use super::*;
1821
1822 #[cfg(unix)]
1826 const FAKE_CLAUDE_ACKS: &str = r#"
1827cap="$1"
1828while IFS= read -r line; do
1829 printf '%s\n' "$line" >> "$cap"
1830 case "$line" in
1831 *'"subtype":"interrupt"'*)
1832 rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1833 printf '{"type":"system","subtype":"mid_flight"}\n'
1834 printf '{"type":"control_response","response":{"subtype":"success","request_id":"someone-elses-request","response":{}}}\n'
1835 printf '{"type":"control_response","response":{"subtype":"success","request_id":"%s","response":{"still_queued":[]}}}\n' "$rid"
1836 ;;
1837 *'"type":"user"'*)
1838 printf '{"type":"assistant","message":{"role":"assistant","content":"replied"}}\n'
1839 ;;
1840 esac
1841done
1842"#;
1843
1844 #[cfg(unix)]
1847 const FAKE_CLAUDE_NEVER_ACKS: &str = r#"
1848cap="$1"
1849while IFS= read -r line; do
1850 printf '%s\n' "$line" >> "$cap"
1851done
1852"#;
1853
1854 #[cfg(unix)]
1861 const FAKE_CLAUDE_ASKS_PERMISSION: &str = r#"
1862cap="$1"
1863printf '{"type":"control_request","request_id":"053f8a2d-3445-4011-a259-4261b31c7326","request":{"subtype":"can_use_tool","tool_name":"Bash","display_name":"Bash","input":{"command":"touch probe-artifact.txt","description":"probe"},"description":"probe","permission_suggestions":[{"type":"addRules","rules":[{"toolName":"Bash","ruleContent":"touch probe-artifact.txt"}],"behavior":"allow","destination":"localSettings"}],"tool_use_id":"toolu_mock_1"}}\n'
1864while IFS= read -r line; do
1865 printf '%s\n' "$line" >> "$cap"
1866 case "$line" in
1867 *'"request_id":"053f8a2d-3445-4011-a259-4261b31c7326"'*)
1868 case "$line" in
1869 *'"behavior":"allow"'*) printf '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_mock_1","content":"(Bash completed with no output)","is_error":false}]}}\n' ;;
1870 *) printf '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_mock_1","content":"denied","is_error":true}]}}\n' ;;
1871 esac
1872 ;;
1873 esac
1874done
1875"#;
1876
1877 #[cfg(unix)]
1879 const FAKE_CLAUDE_REJECTS: &str = r#"
1880cap="$1"
1881while IFS= read -r line; do
1882 printf '%s\n' "$line" >> "$cap"
1883 case "$line" in
1884 *'"subtype":"interrupt"'*)
1885 rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1886 printf '{"type":"control_response","response":{"subtype":"error","request_id":"%s","error":"no active worker"}}\n' "$rid"
1887 ;;
1888 esac
1889done
1890"#;
1891
1892 #[cfg(unix)]
1893 struct FakeClaude {
1894 connection: ClaudeRuntimeConnection,
1895 capture: std::path::PathBuf,
1896 _dir: std::path::PathBuf,
1897 }
1898
1899 #[cfg(unix)]
1900 impl FakeClaude {
1901 async fn spawn(script: &str, control_timeout: Duration) -> Self {
1902 Self::spawn_with(script, control_timeout, CLAUDE_PERMISSION_RESPONSE_TIMEOUT).await
1903 }
1904
1905 async fn spawn_with(
1906 script: &str,
1907 control_timeout: Duration,
1908 permission_timeout: Duration,
1909 ) -> Self {
1910 let dir = std::env::temp_dir().join(format!(
1911 "supercode-fake-claude-{}-{}",
1912 std::process::id(),
1913 generated_session_id()
1914 ));
1915 std::fs::create_dir_all(&dir).unwrap();
1916 let capture = dir.join("stdin.jsonl");
1917 let launch = RuntimeLaunch {
1918 program: "/bin/sh".into(),
1919 arguments: vec![
1920 "-c".into(),
1921 script.into(),
1922 "fake-claude".into(),
1923 capture.display().to_string(),
1924 ],
1925 env: BTreeMap::new(),
1926 };
1927 let transport = RawLineTransport::spawn(&launch, Some(&dir), "claude-stream-json")
1928 .await
1929 .unwrap();
1930 let connection = ClaudeRuntimeConnection {
1931 handle: RuntimeHandle {
1932 harness: HarnessId::from(HarnessId::CLAUDE_CODE),
1933 runtime_id: "fake-session".into(),
1934 endpoint: transport.endpoint.clone(),
1935 },
1936 transport,
1937 prefix: launch.clone(),
1938 cwd: dir.clone(),
1939 spoke: false,
1940 buffered_events: VecDeque::new(),
1941 next_control_request: 1,
1942 control_timeout,
1943 pending_permissions: Vec::new(),
1944 permission_timeout,
1945 };
1946 Self {
1947 connection,
1948 capture,
1949 _dir: dir,
1950 }
1951 }
1952
1953 fn written_frames(&self) -> Vec<Value> {
1954 std::fs::read_to_string(&self.capture)
1955 .unwrap_or_default()
1956 .lines()
1957 .filter(|line| !line.trim().is_empty())
1958 .map(|line| serde_json::from_str(line).expect("adapter wrote a non-JSON frame"))
1959 .collect()
1960 }
1961 }
1962
1963 #[cfg(unix)]
1964 #[tokio::test]
1965 async fn claude_interrupt_writes_one_control_request_per_call_with_a_fresh_id() {
1966 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1967
1968 fake.connection.interrupt().await.unwrap();
1969 fake.connection.interrupt().await.unwrap();
1970
1971 let frames = fake.written_frames();
1972 assert_eq!(
1973 frames.len(),
1974 2,
1975 "each interrupt must write exactly one control frame: {frames:?}"
1976 );
1977 let mut ids = Vec::new();
1978 for frame in &frames {
1979 assert_eq!(frame["type"], "control_request");
1980 assert_eq!(frame["request"]["subtype"], "interrupt");
1981 let id = frame["request_id"].as_str().expect("frame carries an id");
1982 assert!(!id.is_empty());
1983 ids.push(id.to_string());
1984 }
1985 assert_ne!(ids[0], ids[1], "request ids must be unique per call");
1986 }
1987
1988 #[cfg(unix)]
1993 #[tokio::test]
1994 async fn claude_interrupt_with_no_turn_in_flight_is_acknowledged_and_the_session_survives() {
1995 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1996
1997 fake.connection.interrupt().await.unwrap();
1998 fake.connection
1999 .send_input(RuntimeInput {
2000 text: String::new(),
2001 image_urls: vec!["data:image/png;base64,aGVsbG8=".into()],
2002 })
2003 .await
2004 .unwrap();
2005
2006 let mut kinds = Vec::new();
2011 while kinds.len() < 2 {
2012 let event = fake.connection.next_event().await.unwrap().unwrap();
2013 assert_ne!(event.kind, "control_response");
2014 kinds.push(event.kind);
2015 }
2016 assert_eq!(kinds, vec!["system".to_string(), "assistant".to_string()]);
2017
2018 let frames = fake.written_frames();
2019 assert_eq!(frames[0]["type"], "control_request");
2020 assert_eq!(
2021 frames[1]["type"], "user",
2022 "a send issued after an interrupt must reach the harness, in order"
2023 );
2024 assert_eq!(
2025 frames[1]["message"]["content"][0]["source"],
2026 json!({"type":"base64", "media_type":"image/png", "data":"aGVsbG8="}),
2027 "an image-only turn must remain native without a synthetic text block"
2028 );
2029 }
2030
2031 #[cfg(unix)]
2032 #[tokio::test]
2033 async fn claude_interrupt_times_out_with_a_structured_error_instead_of_hanging() {
2034 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_NEVER_ACKS, Duration::from_millis(250)).await;
2035
2036 let started = std::time::Instant::now();
2037 let error = fake.connection.interrupt().await.unwrap_err();
2038
2039 assert!(
2040 started.elapsed() < Duration::from_secs(5),
2041 "interrupt must return on its own bound, not hang"
2042 );
2043 assert!(
2044 error
2045 .to_string()
2046 .contains("did not acknowledge the interrupt"),
2047 "unexpected error: {error}"
2048 );
2049 assert_eq!(fake.written_frames().len(), 1);
2050 }
2051
2052 #[cfg(unix)]
2053 #[tokio::test]
2054 async fn claude_interrupt_surfaces_a_rejecting_control_response_as_an_error() {
2055 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_REJECTS, Duration::from_secs(5)).await;
2056
2057 let error = fake.connection.interrupt().await.unwrap_err();
2058
2059 assert!(
2060 error.to_string().contains("no active worker"),
2061 "unexpected error: {error}"
2062 );
2063 }
2064
2065 #[test]
2066 fn claude_code_runtime_advertises_mid_turn_controls() {
2067 let capabilities = ClaudeCodeRuntimeBackend::new().capabilities();
2068 assert!(capabilities.interrupt);
2069 assert!(capabilities.steer);
2070 assert!(capabilities.respond_to_requests);
2071 }
2072
2073 #[test]
2079 fn claude_code_launches_as_the_cli_permission_handler() {
2080 let backend = ClaudeCodeRuntimeBackend::new();
2081 let arguments = backend.launch.arguments.join(" ");
2082 assert!(
2083 arguments.contains("--permission-prompt-tool stdio"),
2084 "the default launch must register supercode as the permission handler: {arguments}"
2085 );
2086 assert!(arguments.contains("--input-format stream-json"));
2087 assert!(arguments.contains("--output-format stream-json"));
2088 }
2089
2090 #[cfg(unix)]
2096 #[tokio::test]
2097 async fn claude_permission_request_surfaces_and_respond_allows_the_blocked_tool() {
2098 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2099
2100 let request = fake.connection.next_event().await.unwrap().unwrap();
2101 assert_eq!(request.kind, "control_request");
2102 assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
2103 let request_id = request.payload["request_id"].clone();
2104
2105 fake.connection
2106 .respond(request_id.clone(), json!({"behavior": "allow"}))
2107 .await
2108 .unwrap();
2109
2110 let result = fake.connection.next_event().await.unwrap().unwrap();
2111 assert_eq!(result.kind, "user");
2112 assert_eq!(
2113 result.payload["message"]["content"][0]["is_error"],
2114 json!(false),
2115 "the allowed tool must have run: {}",
2116 result.payload
2117 );
2118
2119 let frames = fake.written_frames();
2120 assert_eq!(frames.len(), 1, "one answer per request: {frames:?}");
2121 assert_eq!(
2122 frames[0],
2123 json!({
2124 "type": "control_response",
2125 "response": {
2126 "subtype": "success",
2127 "request_id": request_id,
2128 "response": {"behavior": "allow"},
2129 },
2130 }),
2131 "the answer must be the envelope claude 2.1.258 accepts"
2132 );
2133 }
2134
2135 #[cfg(unix)]
2139 #[tokio::test]
2140 async fn claude_permission_deny_blocks_the_tool_and_always_carries_a_message() {
2141 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2142
2143 let request = fake.connection.next_event().await.unwrap().unwrap();
2144 fake.connection
2145 .respond(
2146 request.payload["request_id"].clone(),
2147 json!({"behavior": "deny"}),
2148 )
2149 .await
2150 .unwrap();
2151
2152 let result = fake.connection.next_event().await.unwrap().unwrap();
2153 assert_eq!(
2154 result.payload["message"]["content"][0]["is_error"],
2155 json!(true),
2156 "a denied tool must not run: {}",
2157 result.payload
2158 );
2159
2160 let frames = fake.written_frames();
2161 let message = frames[0]["response"]["response"]["message"]
2162 .as_str()
2163 .expect("deny must carry a message");
2164 assert!(!message.is_empty(), "{frames:?}");
2165 assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2166 }
2167
2168 #[cfg(unix)]
2172 #[tokio::test]
2173 async fn claude_permission_answers_outside_the_protocol_are_refused_by_name() {
2174 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2175 let request = fake.connection.next_event().await.unwrap().unwrap();
2176 let request_id = request.payload["request_id"].clone();
2177
2178 let error = fake
2179 .connection
2180 .respond(request_id.clone(), json!({"outcome": "selected"}))
2181 .await
2182 .unwrap_err();
2183 assert!(error.to_string().contains("`allow`"), "{error}");
2184 assert!(error.to_string().contains("`deny`"), "{error}");
2185
2186 let error = fake
2187 .connection
2188 .respond(json!("not-a-live-request"), json!({"behavior": "allow"}))
2189 .await
2190 .unwrap_err();
2191 assert!(error.to_string().contains("not-a-live-request"), "{error}");
2192
2193 assert!(fake.written_frames().is_empty());
2195 fake.connection
2196 .respond(request_id, json!({"behavior": "allow"}))
2197 .await
2198 .unwrap();
2199 let result = fake.connection.next_event().await.unwrap().unwrap();
2202 assert_eq!(result.payload["message"]["content"][0]["is_error"], false);
2203 assert_eq!(fake.written_frames().len(), 1);
2204 }
2205
2206 #[cfg(unix)]
2210 #[tokio::test]
2211 async fn an_unanswered_claude_permission_request_is_denied_on_the_adapter_bound() {
2212 let mut fake = FakeClaude::spawn_with(
2213 FAKE_CLAUDE_ASKS_PERMISSION,
2214 Duration::from_secs(5),
2215 Duration::from_millis(250),
2216 )
2217 .await;
2218
2219 let request = fake.connection.next_event().await.unwrap().unwrap();
2220 assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
2221
2222 let result = tokio::time::timeout(Duration::from_secs(5), fake.connection.next_event())
2223 .await
2224 .expect("the adapter must deny on its own bound rather than hang")
2225 .unwrap()
2226 .unwrap();
2227 assert_eq!(
2228 result.payload["message"]["content"][0]["is_error"],
2229 json!(true),
2230 "an unanswered request must deny: {}",
2231 result.payload
2232 );
2233
2234 let frames = fake.written_frames();
2235 assert_eq!(frames.len(), 1, "{frames:?}");
2236 assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2237 assert!(frames[0]["response"]["response"]["message"]
2238 .as_str()
2239 .unwrap()
2240 .contains("timeout"));
2241 }
2242
2243 #[test]
2244 fn capability_reports_distinguish_resume_from_process_attach() {
2245 assert!(
2246 !PiRuntimeBackend::new()
2247 .capabilities()
2248 .attach_existing_process
2249 );
2250 assert!(
2251 !ClaudeCodeRuntimeBackend::new()
2252 .capabilities()
2253 .attach_existing_process
2254 );
2255 assert!(
2256 !OpenCodeRuntimeBackend::new()
2257 .capabilities()
2258 .attach_existing_process
2259 );
2260 assert!(
2261 OpenCodeRuntimeBackend::connect("http://127.0.0.1:4096")
2262 .capabilities()
2263 .attach_existing_process
2264 );
2265 }
2266
2267 #[test]
2268 fn generated_ids_are_uuid_shaped_and_unique() {
2269 let first = generated_session_id();
2270 let second = generated_session_id();
2271 assert_eq!(first.len(), 36);
2272 assert_ne!(first, second);
2273 }
2274
2275 #[test]
2276 fn opencode_event_session_id_covers_current_event_shapes() {
2277 assert_eq!(
2278 opencode_event_session_id(&json!({
2279 "type": "session.status",
2280 "properties": {"sessionID": "session-direct", "status": {"type": "busy"}}
2281 })),
2282 Some("session-direct")
2283 );
2284 assert_eq!(
2285 opencode_event_session_id(&json!({
2286 "type": "message.part.updated",
2287 "properties": {"part": {"sessionID": "session-part", "type": "text"}}
2288 })),
2289 Some("session-part")
2290 );
2291 assert_eq!(
2292 opencode_event_session_id(&json!({
2293 "type": "message.updated",
2294 "properties": {"info": {"sessionID": "session-info", "role": "assistant"}}
2295 })),
2296 Some("session-info")
2297 );
2298 assert_eq!(
2299 opencode_event_session_id(&json!({"type": "server.connected"})),
2300 None
2301 );
2302 }
2303
2304 #[tokio::test]
2305 async fn opencode_runtime_skips_events_for_other_sessions() {
2306 let (sender, receiver) = mpsc::unbounded_channel();
2307 sender
2308 .send(json!({
2309 "type": "session.idle",
2310 "properties": {"sessionID": "foreign-session"}
2311 }))
2312 .unwrap();
2313 sender
2314 .send(json!({
2315 "type": "message.part.delta",
2316 "properties": {"sessionID": "local-session", "delta": "hello"}
2317 }))
2318 .unwrap();
2319 let mut connection = OpenCodeRuntimeConnection {
2320 handle: RuntimeHandle {
2321 harness: HarnessId::from(HarnessId::OPENCODE),
2322 runtime_id: "local-session".into(),
2323 endpoint: RuntimeEndpoint::Http {
2324 base_url: "http://127.0.0.1:1".into(),
2325 protocol: "opencode-http".into(),
2326 },
2327 },
2328 base_url: "http://127.0.0.1:1".into(),
2329 cwd: "/tmp".into(),
2330 client: reqwest::Client::new(),
2331 receiver,
2332 child: None,
2333 };
2334
2335 let event = connection.next_event().await.unwrap().unwrap();
2336
2337 assert_eq!(event.kind, "message.part.delta");
2338 assert_eq!(event.payload["properties"]["sessionID"], "local-session");
2339 }
2340
2341 #[cfg(unix)]
2342 #[tokio::test]
2343 async fn opencode_shutdown_reaps_a_launcher_process_group() {
2344 let mut command = Command::new("/bin/sh");
2345 command
2346 .args(["-c", "sleep 30 & wait"])
2347 .stdin(Stdio::null())
2348 .stdout(Stdio::null())
2349 .stderr(Stdio::null())
2350 .kill_on_drop(true)
2351 .process_group(0);
2352 let mut child = command.spawn().unwrap();
2353 let pid = child.id().unwrap();
2354
2355 terminate_opencode_server(&mut child).await.unwrap();
2356
2357 assert!(child.try_wait().unwrap().is_some());
2358 let group_still_exists = unsafe { libc::kill(-(pid as libc::pid_t), 0) } == 0;
2359 assert!(
2360 !group_still_exists,
2361 "OpenCode worker process group survived close"
2362 );
2363 }
2364
2365 #[cfg(unix)]
2366 #[tokio::test]
2367 async fn opencode_shutdown_reaps_workers_after_launcher_exit() {
2368 let mut command = Command::new("/bin/sh");
2369 command
2370 .args(["-c", "sleep 30 & exit 0"])
2371 .stdin(Stdio::null())
2372 .stdout(Stdio::null())
2373 .stderr(Stdio::null())
2374 .kill_on_drop(true)
2375 .process_group(0);
2376 let mut child = command.spawn().unwrap();
2377 let pid = child.id().unwrap();
2378 tokio::time::sleep(Duration::from_millis(200)).await;
2379
2380 terminate_opencode_server(&mut child).await.unwrap();
2381
2382 assert!(child.try_wait().unwrap().is_some());
2383 assert!(
2384 !process_group_exists(pid),
2385 "OpenCode worker process group survived its exited launcher"
2386 );
2387 }
2388
2389 #[tokio::test]
2390 async fn opencode_health_probe_is_bounded_when_a_socket_never_responds() {
2391 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2392 let address = listener.local_addr().unwrap();
2393 let server = tokio::spawn(async move {
2394 let (_socket, _) = listener.accept().await.unwrap();
2395 tokio::time::sleep(Duration::from_secs(30)).await;
2396 });
2397 let started = tokio::time::Instant::now();
2398
2399 let error = wait_for_health_for(
2400 &reqwest::Client::new(),
2401 &format!("http://{address}"),
2402 Duration::from_millis(200),
2403 )
2404 .await
2405 .unwrap_err();
2406
2407 assert!(error.to_string().contains("health request timed out"));
2408 assert!(started.elapsed() < Duration::from_secs(1));
2409 server.abort();
2410 }
2411
2412 #[cfg(unix)]
2413 #[tokio::test]
2414 async fn acp_adapter_negotiates_starts_and_streams_without_blocking_prompt() {
2415 let script = r#"
2416 i=0
2417 while IFS= read -r line; do
2418 i=$((i + 1))
2419 case "$i" in
2420 1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
2421 2) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"acp_mock"}}' ;;
2422 3)
2423 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp_mock","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}'
2424 printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2425 ;;
2426 esac
2427 done
2428 "#;
2429 let backend = AcpRuntimeBackend::new(
2430 HarnessId::from("mock-acp"),
2431 RuntimeLaunch {
2432 program: "/bin/sh".into(),
2433 arguments: vec!["-c".into(), script.into()],
2434 env: BTreeMap::new(),
2435 },
2436 );
2437 let mut connection = backend
2438 .start(RuntimeStartRequest {
2439 cwd: std::env::current_dir().unwrap(),
2440 launch: None,
2441 mcp_servers: Vec::new(),
2442 })
2443 .await
2444 .unwrap();
2445 assert_eq!(connection.handle().runtime_id, "acp_mock");
2446 assert_eq!(
2447 connection
2448 .send_input(RuntimeInput {
2449 text: "hi".into(),
2450 image_urls: Vec::new(),
2451 })
2452 .await
2453 .unwrap()
2454 .as_deref(),
2455 Some("3")
2456 );
2457 assert_eq!(
2458 connection.next_event().await.unwrap().unwrap().kind,
2459 "session/update"
2460 );
2461 assert_eq!(
2462 connection.next_event().await.unwrap().unwrap().kind,
2463 "supercode/acp_request_completed"
2464 );
2465 connection.close().await.unwrap();
2466 }
2467
2468 #[cfg(unix)]
2472 #[tokio::test]
2473 async fn acp_start_forwards_mcp_servers_into_session_new() {
2474 let capture = std::env::temp_dir().join(format!(
2475 "supercode-acp-mcp-{}-{}.json",
2476 std::process::id(),
2477 std::time::SystemTime::now()
2478 .duration_since(std::time::UNIX_EPOCH)
2479 .unwrap()
2480 .as_nanos()
2481 ));
2482 let script = format!(
2483 r#"
2484 i=0
2485 while IFS= read -r line; do
2486 i=$((i + 1))
2487 case "$i" in
2488 1) printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":1,"agentCapabilities":{{}},"authMethods":[]}}}}' ;;
2489 2)
2490 printf '%s\n' "$line" > {capture}
2491 printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"sessionId":"acp_mock"}}}}'
2492 ;;
2493 esac
2494 done
2495 "#,
2496 capture = capture.display()
2497 );
2498 let backend = AcpRuntimeBackend::new(
2499 HarnessId::from("mock-acp"),
2500 RuntimeLaunch {
2501 program: "/bin/sh".into(),
2502 arguments: vec!["-c".into(), script],
2503 env: BTreeMap::new(),
2504 },
2505 );
2506 let mut connection = backend
2507 .start(RuntimeStartRequest {
2508 cwd: std::env::current_dir().unwrap(),
2509 launch: None,
2510 mcp_servers: vec![McpServerLaunch {
2511 name: "orchestrator".into(),
2512 command: "/usr/bin/node".into(),
2513 arguments: vec!["/tmp/server.mjs".into()],
2514 env: BTreeMap::from([(
2515 "SUPERCODE_ORCHESTRATOR_PROFILE".into(),
2516 "coder".into(),
2517 )]),
2518 }],
2519 })
2520 .await
2521 .unwrap();
2522 connection.close().await.unwrap();
2523
2524 let sent: Value =
2525 serde_json::from_str(&std::fs::read_to_string(&capture).unwrap()).unwrap();
2526 let _ = std::fs::remove_file(&capture);
2527 assert_eq!(sent["method"], "session/new");
2528 assert_eq!(
2529 sent["params"]["mcpServers"],
2530 json!([{
2531 "name": "orchestrator",
2532 "command": "/usr/bin/node",
2533 "args": ["/tmp/server.mjs"],
2534 "env": [{"name": "SUPERCODE_ORCHESTRATOR_PROFILE", "value": "coder"}],
2535 }])
2536 );
2537 }
2538
2539 #[cfg(unix)]
2540 #[tokio::test]
2541 async fn acp_uses_an_existing_login_before_trying_an_advertised_auth_method() {
2542 let script = r#"
2543 i=0
2544 while IFS= read -r line; do
2545 i=$((i + 1))
2546 if [ "$i" -eq 1 ]; then
2547 printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[{"id":"cached_token"}]}}'
2548 elif printf '%s' "$line" | grep -q 'session/new'; then
2549 printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"existing_login"}}'
2550 else
2551 exit 9
2552 fi
2553 done
2554 "#;
2555 let backend = AcpRuntimeBackend::new(
2556 HarnessId::from("mock-acp"),
2557 RuntimeLaunch {
2558 program: "/bin/sh".into(),
2559 arguments: vec!["-c".into(), script.into()],
2560 env: BTreeMap::new(),
2561 },
2562 );
2563 let mut connection = backend
2564 .start(RuntimeStartRequest {
2565 cwd: std::env::current_dir().unwrap(),
2566 launch: None,
2567 mcp_servers: Vec::new(),
2568 })
2569 .await
2570 .unwrap();
2571 assert_eq!(connection.handle().runtime_id, "existing_login");
2572 connection.close().await.unwrap();
2573 }
2574
2575 #[cfg(unix)]
2576 #[tokio::test]
2577 async fn known_acp_agent_reports_and_uses_load_session_for_resume() {
2578 let script = r#"
2579 i=0
2580 while IFS= read -r line; do
2581 i=$((i + 1))
2582 case "$i" in
2583 1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true},"authMethods":[]}}' ;;
2584 2)
2585 case "$line" in
2586 *'"method":"session/load"'*'"sessionId":"existing-session"'*)
2587 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"historical replay"}}}}'
2588 printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{}}'
2589 ;;
2590 *) exit 42 ;;
2591 esac
2592 ;;
2593 3)
2594 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fresh output"}}}}'
2595 printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2596 ;;
2597 esac
2598 done
2599 "#;
2600 let backend = AcpRuntimeBackend::new(
2601 HarnessId::from("known-acp"),
2602 RuntimeLaunch {
2603 program: "/bin/sh".into(),
2604 arguments: vec!["-c".into(), script.into()],
2605 env: BTreeMap::new(),
2606 },
2607 )
2608 .with_resume_support(true);
2609 assert!(backend.capabilities().resume_session);
2610 let mut connection = backend
2611 .attach(RuntimeAttachRequest {
2612 runtime_id: "existing-session".into(),
2613 cwd: Some(std::env::current_dir().unwrap()),
2614 launch: None,
2615 })
2616 .await
2617 .unwrap();
2618 assert_eq!(connection.handle().runtime_id, "existing-session");
2619 assert_eq!(
2620 connection
2621 .send_input(RuntimeInput {
2622 text: "continue".into(),
2623 image_urls: Vec::new(),
2624 })
2625 .await
2626 .unwrap()
2627 .as_deref(),
2628 Some("3")
2629 );
2630 let event = connection.next_event().await.unwrap().unwrap();
2631 assert_eq!(event.kind, "session/update");
2632 assert_eq!(
2633 event
2634 .payload
2635 .pointer("/params/update/content/text")
2636 .and_then(Value::as_str),
2637 Some("fresh output")
2638 );
2639 assert_eq!(
2640 connection.next_event().await.unwrap().unwrap().kind,
2641 "supercode/acp_request_completed"
2642 );
2643 connection.close().await.unwrap();
2644 }
2645}