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 mcp_servers: &[McpServerLaunch],
246 resume: bool,
247 ) -> Result<Box<dyn RuntimeConnection>> {
248 let mut prefix = launch.unwrap_or_else(|| self.launch.clone());
252 if !mcp_servers.is_empty() {
256 let file = claude_mcp_config_file(&runtime_id, mcp_servers).await?;
257 prefix
258 .arguments
259 .extend(["--mcp-config".into(), file.to_string_lossy().into_owned()]);
260 }
261 let mut launch = prefix.clone();
262 launch.arguments.extend(if resume {
263 vec!["--resume".into(), runtime_id.clone()]
264 } else {
265 vec!["--session-id".into(), runtime_id.clone()]
266 });
267 let transport = RawLineTransport::spawn(&launch, Some(cwd), "claude-stream-json").await?;
268 Ok(Box::new(ClaudeRuntimeConnection {
269 handle: RuntimeHandle {
270 harness: HarnessId::from(HarnessId::CLAUDE_CODE),
271 runtime_id,
272 endpoint: transport.endpoint.clone(),
273 },
274 transport,
275 prefix,
276 cwd: cwd.to_path_buf(),
277 spoke: false,
278 buffered_events: VecDeque::new(),
279 next_control_request: 1,
280 control_timeout: CLAUDE_CONTROL_RESPONSE_TIMEOUT,
281 pending_permissions: Vec::new(),
282 permission_timeout: self.permission_timeout,
283 }))
284 }
285}
286
287const CLAUDE_CONTROL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
297
298pub const CLAUDE_PERMISSION_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300);
311
312const CLAUDE_PERMISSION_BEHAVIORS: [&str; 2] = ["allow", "deny"];
319
320const CLAUDE_PERMISSION_TIMEOUT_MESSAGE: &str =
322 "supercode denied this permission request: no answer arrived before the adapter's \
323 permission timeout elapsed";
324
325#[async_trait]
326impl RuntimeBackend for ClaudeCodeRuntimeBackend {
327 fn harness(&self) -> HarnessId {
328 HarnessId::from(HarnessId::CLAUDE_CODE)
329 }
330
331 fn capabilities(&self) -> RuntimeCapabilities {
332 RuntimeCapabilities {
333 start_session: true,
334 resume_session: true,
335 attach_existing_process: false,
336 send_input: true,
337 stream_events: true,
338 interrupt: true,
339 steer: true,
340 respond_to_requests: true,
341 }
342 }
343
344 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
345 self.open(
346 &request.cwd,
347 generated_session_id(),
348 request.launch,
349 &request.mcp_servers,
350 false,
351 )
352 .await
353 }
354
355 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
356 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
357 self.open(&cwd, request.runtime_id, request.launch, &[], true)
358 .await
359 }
360}
361
362async fn claude_mcp_config_file(runtime_id: &str, servers: &[McpServerLaunch]) -> Result<PathBuf> {
366 let mut entries = serde_json::Map::new();
367 for server in servers {
368 entries.insert(
369 server.name.clone(),
370 json!({
371 "type": "stdio",
372 "command": server.command,
373 "args": server.arguments,
374 "env": server.env,
375 }),
376 );
377 }
378 let safe: String = runtime_id
379 .chars()
380 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
381 .collect();
382 let path = std::env::temp_dir().join(format!("supercode-claude-mcp-{safe}.json"));
383 let body = serde_json::to_vec_pretty(&json!({ "mcpServers": entries })).map_err(|error| {
384 Error::Other(format!("claude mcp config could not be encoded: {error}"))
385 })?;
386 tokio::fs::write(&path, body).await.map_err(|error| {
387 Error::Other(format!("claude mcp config could not be written: {error}"))
388 })?;
389 #[cfg(unix)]
390 {
391 use std::os::unix::fs::PermissionsExt;
392 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
393 .await
394 .map_err(|error| {
395 Error::Other(format!("claude mcp config could not be protected: {error}"))
396 })?;
397 }
398 Ok(path)
399}
400
401struct ClaudeRuntimeConnection {
402 handle: RuntimeHandle,
403 transport: RawLineTransport,
404 prefix: RuntimeLaunch,
408 cwd: PathBuf,
412 spoke: bool,
417 buffered_events: VecDeque<Value>,
421 next_control_request: u64,
422 control_timeout: Duration,
423 pending_permissions: Vec<PendingPermission>,
427 permission_timeout: Duration,
428}
429
430struct PendingPermission {
432 request_id: String,
434 deadline: tokio::time::Instant,
436}
437
438impl ClaudeRuntimeConnection {
439 async fn process_ended(&self) -> bool {
446 matches!(self.transport.child.lock().await.try_wait(), Ok(Some(_)))
447 }
448
449 async fn reopen(&mut self) -> Result<()> {
459 let mut launch = self.prefix.clone();
460 launch
461 .arguments
462 .extend(["--resume".into(), self.handle.runtime_id.clone()]);
463 let transport = RawLineTransport::spawn(&launch, Some(&self.cwd), "claude-stream-json")
464 .await
465 .map_err(|error| {
466 Error::Other(format!(
467 "could not resume Claude Code session `{}` after its process exited: {error}",
468 self.handle.runtime_id
469 ))
470 })?;
471 self.handle.endpoint = transport.endpoint.clone();
472 self.transport = transport;
473 self.pending_permissions.clear();
476 self.spoke = false;
477 Ok(())
478 }
479
480 async fn write_turn(&mut self, frame: Value) -> Result<()> {
488 if self.process_ended().await {
489 self.reopen().await?;
490 }
491 match self.transport.write(frame.clone()).await {
492 Ok(()) => Ok(()),
493 Err(error) if broken_pipe(&error) => {
494 self.reopen().await?;
495 self.transport.write(frame).await
496 }
497 Err(error) => Err(error),
498 }
499 }
500
501 fn is_control_response(value: &Value) -> bool {
506 value.get("type").and_then(Value::as_str) == Some("control_response")
507 }
508
509 fn control_result(value: &Value, request_id: &str) -> Option<Result<()>> {
517 let response = value.get("response")?;
518 if response.get("request_id").and_then(Value::as_str) != Some(request_id) {
519 return None;
520 }
521 match response.get("subtype").and_then(Value::as_str) {
522 Some("success") => Some(Ok(())),
523 other => Some(Err(Error::Other(format!(
524 "Claude Code rejected the interrupt control request: {}",
525 response
526 .get("error")
527 .and_then(Value::as_str)
528 .map(str::to_string)
529 .unwrap_or_else(|| format!(
530 "control_response subtype {}",
531 other.unwrap_or("(missing)")
532 ))
533 )))),
534 }
535 }
536
537 fn permission_request_id(value: &Value) -> Option<&str> {
544 if value.get("type").and_then(Value::as_str)? != "control_request" {
545 return None;
546 }
547 let request = value.get("request")?;
548 if request.get("subtype").and_then(Value::as_str)? != "can_use_tool" {
549 return None;
550 }
551 value.get("request_id").and_then(Value::as_str)
552 }
553
554 fn note_permission_request(&mut self, payload: &Value) {
556 let Some(request_id) = Self::permission_request_id(payload) else {
557 return;
558 };
559 if self
560 .pending_permissions
561 .iter()
562 .any(|pending| pending.request_id == request_id)
563 {
564 return;
565 }
566 self.pending_permissions.push(PendingPermission {
567 request_id: request_id.to_string(),
568 deadline: tokio::time::Instant::now() + self.permission_timeout,
569 });
570 }
571
572 async fn write_permission_response(&mut self, request_id: &str, body: Value) -> Result<()> {
574 self.transport
575 .write(json!({
576 "type": "control_response",
577 "response": {
578 "subtype": "success",
579 "request_id": request_id,
580 "response": body,
581 },
582 }))
583 .await
584 }
585
586 async fn deny_expired_permissions(&mut self) -> Result<()> {
591 let now = tokio::time::Instant::now();
592 let expired = self
593 .pending_permissions
594 .iter()
595 .filter(|pending| pending.deadline <= now)
596 .map(|pending| pending.request_id.clone())
597 .collect::<Vec<_>>();
598 self.pending_permissions
599 .retain(|pending| pending.deadline > now);
600 for request_id in expired {
601 self.write_permission_response(
602 &request_id,
603 json!({"behavior": "deny", "message": CLAUDE_PERMISSION_TIMEOUT_MESSAGE}),
604 )
605 .await?;
606 }
607 Ok(())
608 }
609
610 async fn transport_ended(&mut self) -> Result<Option<HarnessEvent>> {
627 if !self.spoke {
628 return Ok(None);
629 }
630 std::future::pending().await
631 }
632
633 fn next_permission_deadline(&self) -> Option<Duration> {
635 let now = tokio::time::Instant::now();
636 self.pending_permissions
637 .iter()
638 .map(|pending| pending.deadline.saturating_duration_since(now))
639 .min()
640 }
641}
642
643fn claude_permission_result(response: Value) -> Result<Value> {
653 let Value::Object(mut body) = response else {
654 return Err(claude_permission_shape_error(&response));
655 };
656 match body.get("behavior").and_then(Value::as_str) {
657 Some("allow") => {}
658 Some("deny") => {
659 let empty = body
661 .get("message")
662 .and_then(Value::as_str)
663 .is_none_or(str::is_empty);
664 if empty {
665 body.insert(
666 "message".into(),
667 Value::String("supercode denied this permission request".into()),
668 );
669 }
670 }
671 _ => return Err(claude_permission_shape_error(&Value::Object(body))),
672 }
673 Ok(Value::Object(body))
674}
675
676fn claude_permission_shape_error(response: &Value) -> Error {
677 Error::Other(format!(
678 "Claude Code permission answers must carry a `behavior` of {}; got {response}",
679 CLAUDE_PERMISSION_BEHAVIORS
680 .map(|behavior| format!("`{behavior}`"))
681 .join(" or "),
682 ))
683}
684
685#[async_trait]
686impl RuntimeConnection for ClaudeRuntimeConnection {
687 fn handle(&self) -> &RuntimeHandle {
688 &self.handle
689 }
690
691 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
692 let content = if input.image_urls.is_empty() {
693 Value::String(input.text)
694 } else {
695 let mut parts = Vec::new();
696 if !input.text.is_empty() {
697 parts.push(json!({"type":"text", "text":input.text}));
698 }
699 for url in input.image_urls {
700 parts.push(claude_image_part(&url)?);
701 }
702 Value::Array(parts)
703 };
704 self.write_turn(json!({
705 "type": "user",
706 "session_id": self.handle.runtime_id,
707 "message": {"role": "user", "content": content},
708 }))
709 .await?;
710 Ok(None)
711 }
712
713 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
714 if let Some(payload) = self.buffered_events.pop_front() {
715 self.spoke = true;
716 return Ok(Some(harness_event(payload)));
717 }
718 loop {
719 self.deny_expired_permissions().await?;
723 let payload = match self.next_permission_deadline() {
724 Some(remaining) => {
725 match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
726 Err(_) => continue,
727 Ok(None) => return self.transport_ended().await,
728 Ok(Some(payload)) => payload,
729 }
730 }
731 None => match self.transport.receiver.recv().await {
732 None => return self.transport_ended().await,
733 Some(payload) => payload,
734 },
735 };
736 self.spoke = true;
737 if Self::is_control_response(&payload) {
738 continue;
739 }
740 self.note_permission_request(&payload);
741 return Ok(Some(harness_event(payload)));
742 }
743 }
744
745 async fn interrupt(&mut self) -> Result<()> {
754 if self.process_ended().await {
758 return Ok(());
759 }
760 let request_id = format!(
761 "supercode-{}-interrupt-{}",
762 self.handle.runtime_id, self.next_control_request
763 );
764 self.next_control_request += 1;
765 self.transport
766 .write(json!({
767 "type": "control_request",
768 "request_id": request_id,
769 "request": {"subtype": "interrupt"},
770 }))
771 .await?;
772
773 let deadline = tokio::time::Instant::now() + self.control_timeout;
774 loop {
775 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
776 if remaining.is_zero() {
777 return Err(claude_interrupt_timeout(self.control_timeout));
778 }
779 match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
780 Err(_) => return Err(claude_interrupt_timeout(self.control_timeout)),
781 Ok(None) => return Err(Error::Other(
782 "Claude Code stream-json transport closed before acknowledging the interrupt"
783 .into(),
784 )),
785 Ok(Some(payload)) => {
786 if Self::is_control_response(&payload) {
787 if let Some(result) = Self::control_result(&payload, &request_id) {
788 return result;
789 }
790 continue;
791 }
792 self.note_permission_request(&payload);
796 self.buffered_events.push_back(payload);
797 }
798 }
799 }
800 }
801
802 async fn steer(&mut self, text: String) -> Result<()> {
803 self.send_input(RuntimeInput {
804 text,
805 image_urls: Vec::new(),
806 })
807 .await
808 .map(|_| ())
809 }
810
811 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
820 let Some(request_id) = request_id.as_str().map(str::to_string) else {
821 return Err(Error::Other(format!(
822 "Claude Code control requests are identified by a string `request_id`; got \
823 {request_id}"
824 )));
825 };
826 let Some(index) = self
827 .pending_permissions
828 .iter()
829 .position(|pending| pending.request_id == request_id)
830 else {
831 return Err(Error::Other(format!(
832 "no Claude Code permission request `{request_id}` is waiting on this connection — \
833 a `can_use_tool` request is answerable only while its turn is blocked on it, and \
834 only until it is answered or denied on timeout"
835 )));
836 };
837 let body = claude_permission_result(response)?;
838 self.pending_permissions.remove(index);
839 self.write_permission_response(&request_id, body).await
840 }
841
842 async fn close(&mut self) -> Result<()> {
843 self.transport.close().await
844 }
845}
846
847#[derive(Debug, Clone)]
849pub struct AcpRuntimeBackend {
850 harness: HarnessId,
851 launch: RuntimeLaunch,
852 resume_session: bool,
853}
854
855impl AcpRuntimeBackend {
856 pub fn new(harness: HarnessId, launch: RuntimeLaunch) -> Self {
858 Self {
859 harness,
860 launch,
861 resume_session: false,
862 }
863 }
864
865 pub fn with_resume_support(mut self, supported: bool) -> Self {
869 self.resume_session = supported;
870 self
871 }
872
873 async fn connect(
874 &self,
875 cwd: &Path,
876 launch: Option<RuntimeLaunch>,
877 ) -> Result<(
878 Arc<JsonLineClient>,
879 mpsc::UnboundedReceiver<Value>,
880 RuntimeEndpoint,
881 Value,
882 )> {
883 let launch = launch.unwrap_or_else(|| self.launch.clone());
884 let (client, receiver, endpoint) =
885 JsonLineClient::spawn(&launch, Some(cwd), true, "acp-v1-jsonrpc").await?;
886 let initialized = client
887 .request(
888 "initialize",
889 json!({
890 "protocolVersion": 1,
891 "clientCapabilities": {},
892 "clientInfo": {
893 "name": "supercode",
894 "title": "Supercode",
895 "version": env!("CARGO_PKG_VERSION"),
896 },
897 }),
898 )
899 .await?;
900 if initialized.get("protocolVersion").and_then(Value::as_u64) != Some(1) {
901 return Err(Error::Other(format!(
902 "ACP agent negotiated unsupported protocol version: {}",
903 initialized
904 .get("protocolVersion")
905 .cloned()
906 .unwrap_or(Value::Null)
907 )));
908 }
909 Ok((client, receiver, endpoint, initialized))
910 }
911
912 async fn session_request(
913 &self,
914 client: &JsonLineClient,
915 initialized: &Value,
916 method: &str,
917 params: Value,
918 ) -> Result<Value> {
919 match client.request(method, params.clone()).await {
920 Ok(response) => Ok(response),
921 Err(error) if acp_auth_required(&error.to_string()) => {
922 let cached = initialized
923 .get("authMethods")
924 .and_then(Value::as_array)
925 .and_then(|methods| {
926 methods.iter().find_map(|candidate| {
927 (candidate.get("id").and_then(Value::as_str) == Some("cached_token"))
928 .then_some("cached_token")
929 })
930 });
931 let Some(method_id) = cached else {
932 return Err(Error::Other(
933 "ACP agent requires authentication but did not advertise the non-interactive `cached_token` method"
934 .into(),
935 ));
936 };
937 client
938 .request(
939 "authenticate",
940 json!({"methodId": method_id, "_meta": {"headless": true}}),
941 )
942 .await?;
943 client.request(method, params).await
944 }
945 Err(error) => Err(error),
946 }
947 }
948
949 async fn connection(
950 &self,
951 cwd: &Path,
952 runtime_id: Option<String>,
953 launch: Option<RuntimeLaunch>,
954 mcp_servers: Vec<McpServerLaunch>,
955 ) -> Result<Box<dyn RuntimeConnection>> {
956 let mcp_servers = acp_mcp_servers(&mcp_servers);
957 let (client, mut receiver, endpoint, initialized) = self.connect(cwd, launch).await?;
958 let session_id = if let Some(session_id) = runtime_id {
959 let resume = initialized
960 .pointer("/agentCapabilities/sessionCapabilities/resume")
961 .is_some();
962 let load = initialized
963 .pointer("/agentCapabilities/loadSession")
964 .and_then(Value::as_bool)
965 .unwrap_or(false);
966 let method = if resume {
967 "session/resume"
968 } else if load {
969 "session/load"
970 } else {
971 return Err(Error::Other(
972 "ACP agent did not advertise session resume or load".into(),
973 ));
974 };
975 self.session_request(
976 client.as_ref(),
977 &initialized,
978 method,
979 json!({"sessionId": session_id, "cwd": cwd, "mcpServers": mcp_servers}),
980 )
981 .await?;
982 session_id
983 } else {
984 self.session_request(
985 client.as_ref(),
986 &initialized,
987 "session/new",
988 json!({"cwd": cwd, "mcpServers": mcp_servers}),
989 )
990 .await?
991 .get("sessionId")
992 .and_then(Value::as_str)
993 .ok_or_else(|| Error::Other("ACP session/new omitted sessionId".into()))?
994 .to_string()
995 };
996 while receiver.try_recv().is_ok() {}
1005 Ok(Box::new(AcpRuntimeConnection {
1006 handle: RuntimeHandle {
1007 harness: self.harness.clone(),
1008 runtime_id: session_id,
1009 endpoint,
1010 },
1011 client,
1012 receiver,
1013 active_prompt: None,
1014 }))
1015 }
1016}
1017
1018fn acp_mcp_servers(servers: &[McpServerLaunch]) -> Value {
1023 Value::Array(
1024 servers
1025 .iter()
1026 .map(|server| {
1027 json!({
1028 "name": server.name,
1029 "command": server.command,
1030 "args": server.arguments,
1031 "env": server
1032 .env
1033 .iter()
1034 .map(|(name, value)| json!({"name": name, "value": value}))
1035 .collect::<Vec<_>>(),
1036 })
1037 })
1038 .collect::<Vec<_>>(),
1039 )
1040}
1041
1042fn acp_auth_required(message: &str) -> bool {
1043 let message = message.to_ascii_lowercase();
1044 [
1045 "auth",
1046 "login",
1047 "sign in",
1048 "sign-in",
1049 "unauthorized",
1050 "forbidden",
1051 "credential",
1052 ]
1053 .iter()
1054 .any(|needle| message.contains(needle))
1055}
1056
1057#[async_trait]
1058impl RuntimeBackend for AcpRuntimeBackend {
1059 fn harness(&self) -> HarnessId {
1060 self.harness.clone()
1061 }
1062
1063 fn capabilities(&self) -> RuntimeCapabilities {
1064 RuntimeCapabilities {
1065 start_session: true,
1066 resume_session: self.resume_session,
1069 attach_existing_process: false,
1070 send_input: true,
1071 stream_events: true,
1072 interrupt: true,
1073 steer: false,
1074 respond_to_requests: true,
1075 }
1076 }
1077
1078 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
1079 self.connection(&request.cwd, None, request.launch, request.mcp_servers)
1080 .await
1081 }
1082
1083 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
1084 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1085 self.connection(&cwd, Some(request.runtime_id), request.launch, Vec::new())
1086 .await
1087 }
1088}
1089
1090struct AcpRuntimeConnection {
1091 handle: RuntimeHandle,
1092 client: Arc<JsonLineClient>,
1093 receiver: mpsc::UnboundedReceiver<Value>,
1094 active_prompt: Option<u64>,
1095}
1096
1097#[async_trait]
1098impl RuntimeConnection for AcpRuntimeConnection {
1099 fn handle(&self) -> &RuntimeHandle {
1100 &self.handle
1101 }
1102
1103 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
1104 let mut prompt = Vec::new();
1105 if !input.text.is_empty() {
1106 prompt.push(json!({"type": "text", "text": input.text}));
1107 }
1108 for url in input.image_urls {
1109 let (mime_type, data) = data_image_parts(&url).ok_or_else(|| {
1110 Error::Other("ACP image prompts require base64 image data URLs".into())
1111 })?;
1112 prompt.push(json!({"type":"image", "mimeType":mime_type, "data":data}));
1113 }
1114 let (id, response) = self
1115 .client
1116 .begin_request(
1117 "session/prompt",
1118 json!({
1119 "sessionId": self.handle.runtime_id,
1120 "prompt": prompt,
1121 }),
1122 )
1123 .await?;
1124 self.active_prompt = Some(id);
1125 let client = self.client.clone();
1126 tokio::spawn(async move {
1127 let result = match response.await {
1128 Ok(Ok(result)) => json!({"id": id, "result": result}),
1129 Ok(Err(error)) => json!({"id": id, "error": error}),
1130 Err(_) => json!({"id": id, "error": "response channel closed"}),
1131 };
1132 client.emit(json!({
1133 "jsonrpc": "2.0",
1134 "method": "supercode/acp_request_completed",
1135 "params": result,
1136 }));
1137 });
1138 Ok(Some(id.to_string()))
1139 }
1140
1141 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1142 let Some(payload) = self.receiver.recv().await else {
1143 return Ok(None);
1144 };
1145 let kind = payload
1146 .get("method")
1147 .and_then(Value::as_str)
1148 .or_else(|| payload.get("type").and_then(Value::as_str))
1149 .unwrap_or("protocol")
1150 .to_string();
1151 if kind == "supercode/acp_request_completed" {
1152 self.active_prompt = None;
1153 }
1154 Ok(Some(HarnessEvent {
1155 sequence: None,
1156 kind,
1157 payload,
1158 }))
1159 }
1160
1161 async fn interrupt(&mut self) -> Result<()> {
1162 self.client
1163 .notify(
1164 "session/cancel",
1165 json!({"sessionId": self.handle.runtime_id}),
1166 )
1167 .await
1168 }
1169
1170 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1171 self.client.respond(request_id, response).await
1172 }
1173
1174 async fn close(&mut self) -> Result<()> {
1175 self.client.close().await
1176 }
1177}
1178
1179#[derive(Debug, Clone)]
1183pub struct OpenCodeRuntimeBackend {
1184 launch: RuntimeLaunch,
1185 base_url: Option<String>,
1186 bearer: Option<BearerToken>,
1187}
1188
1189impl Default for OpenCodeRuntimeBackend {
1190 fn default() -> Self {
1191 Self::new()
1192 }
1193}
1194
1195impl OpenCodeRuntimeBackend {
1196 pub fn new() -> Self {
1198 Self {
1199 launch: RuntimeLaunch {
1200 program: "opencode".into(),
1201 arguments: vec!["serve".into()],
1202 env: BTreeMap::new(),
1203 },
1204 base_url: None,
1205 bearer: None,
1206 }
1207 }
1208
1209 pub fn connect(base_url: impl Into<String>) -> Self {
1212 Self {
1213 base_url: Some(base_url.into().trim_end_matches('/').to_string()),
1214 ..Self::new()
1215 }
1216 }
1217
1218 pub fn with_launch(mut self, launch: RuntimeLaunch) -> Self {
1220 self.launch = launch;
1221 self
1222 }
1223
1224 pub fn with_bearer(mut self, token: BearerToken) -> Self {
1227 self.bearer = Some(token);
1228 self
1229 }
1230
1231 fn http_client(&self) -> Result<reqwest::Client> {
1232 let Some(token) = &self.bearer else {
1233 return Ok(reqwest::Client::new());
1234 };
1235 let mut headers = reqwest::header::HeaderMap::new();
1236 let mut value =
1237 reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token.secret())).map_err(
1238 |_| Error::Other("connect-mode bearer token is not a valid header value".into()),
1239 )?;
1240 value.set_sensitive(true);
1241 headers.insert(reqwest::header::AUTHORIZATION, value);
1242 reqwest::Client::builder()
1243 .default_headers(headers)
1244 .build()
1245 .map_err(|error| Error::Other(format!("could not build HTTP client: {error}")))
1246 }
1247
1248 async fn service(
1249 &self,
1250 client: &reqwest::Client,
1251 launch: Option<RuntimeLaunch>,
1252 ) -> Result<(String, Option<super::GroupLeader>)> {
1253 if let Some(base_url) = &self.base_url {
1254 wait_for_health(client, base_url).await?;
1255 return Ok((base_url.clone(), None));
1256 }
1257 let port = TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port();
1258 let mut launch = launch.unwrap_or_else(|| self.launch.clone());
1259 launch.arguments.extend([
1260 "--hostname".into(),
1261 "127.0.0.1".into(),
1262 "--port".into(),
1263 port.to_string(),
1264 ]);
1265 let mut command = Command::new(&launch.program);
1266 command
1267 .args(&launch.arguments)
1268 .envs(&launch.env)
1269 .stdin(Stdio::null())
1270 .stdout(Stdio::null())
1271 .stderr(Stdio::inherit())
1272 .kill_on_drop(true);
1273 #[cfg(unix)]
1277 command.process_group(0);
1278 let mut child = super::GroupLeader(command.spawn().map_err(|error| {
1283 Error::Other(format!("could not launch {}: {error}", launch.program))
1284 })?);
1285 let base_url = format!("http://127.0.0.1:{port}");
1286 if let Err(error) = wait_for_health(client, &base_url).await {
1287 let _ = terminate_opencode_server(&mut child).await;
1288 return Err(error);
1289 }
1290 Ok((base_url, Some(child)))
1291 }
1292
1293 async fn open(
1294 &self,
1295 cwd: &Path,
1296 runtime_id: Option<String>,
1297 launch: Option<RuntimeLaunch>,
1298 ) -> Result<Box<dyn RuntimeConnection>> {
1299 let client = self.http_client()?;
1300 let (base_url, child) = self.service(&client, launch).await?;
1301 let cwd_string = cwd.to_string_lossy().to_string();
1302 let runtime_id = match runtime_id {
1303 Some(id) => {
1304 http_ok(
1305 client
1306 .get(format!("{base_url}/session/{id}"))
1307 .query(&[("directory", &cwd_string)])
1308 .send()
1309 .await,
1310 )
1311 .await?;
1312 id
1313 }
1314 None => {
1315 let response = http_ok(
1316 client
1317 .post(format!("{base_url}/session"))
1318 .query(&[("directory", &cwd_string)])
1319 .json(&json!({}))
1320 .send()
1321 .await,
1322 )
1323 .await?;
1324 response
1325 .json::<Value>()
1326 .await
1327 .map_err(http_error)?
1328 .get("id")
1329 .and_then(Value::as_str)
1330 .ok_or_else(|| Error::Other("OpenCode create session omitted id".into()))?
1331 .to_string()
1332 }
1333 };
1334 let receiver = spawn_sse(
1335 client.clone(),
1336 format!("{base_url}/event"),
1337 cwd_string.clone(),
1338 );
1339 Ok(Box::new(OpenCodeRuntimeConnection {
1340 handle: RuntimeHandle {
1341 harness: HarnessId::from(HarnessId::OPENCODE),
1342 runtime_id,
1343 endpoint: RuntimeEndpoint::Http {
1344 base_url: base_url.clone(),
1345 protocol: "opencode-http-sse".into(),
1346 },
1347 },
1348 base_url,
1349 cwd: cwd_string,
1350 client,
1351 receiver,
1352 child,
1353 }))
1354 }
1355}
1356
1357#[async_trait]
1358impl RuntimeBackend for OpenCodeRuntimeBackend {
1359 fn harness(&self) -> HarnessId {
1360 HarnessId::from(HarnessId::OPENCODE)
1361 }
1362
1363 fn capabilities(&self) -> RuntimeCapabilities {
1364 RuntimeCapabilities {
1365 start_session: true,
1366 resume_session: true,
1367 attach_existing_process: self.base_url.is_some(),
1368 send_input: true,
1369 stream_events: true,
1370 interrupt: true,
1371 steer: false,
1372 respond_to_requests: true,
1373 }
1374 }
1375
1376 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
1377 self.open(&request.cwd, None, request.launch).await
1378 }
1379
1380 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
1381 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1382 self.open(&cwd, Some(request.runtime_id), request.launch)
1383 .await
1384 }
1385
1386 async fn attach_existing(
1387 &self,
1388 request: RuntimeAttachRequest,
1389 ) -> Result<Box<dyn RuntimeConnection>> {
1390 if self.base_url.is_none() {
1391 return Err(Error::Other(
1392 "OpenCode live attach requires the existing server's `base_url`".into(),
1393 ));
1394 }
1395 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1396 self.open(&cwd, Some(request.runtime_id), request.launch)
1397 .await
1398 }
1399}
1400
1401struct OpenCodeRuntimeConnection {
1402 handle: RuntimeHandle,
1403 base_url: String,
1404 cwd: String,
1405 client: reqwest::Client,
1406 receiver: mpsc::UnboundedReceiver<Value>,
1407 child: Option<super::GroupLeader>,
1408}
1409
1410#[async_trait]
1411impl RuntimeConnection for OpenCodeRuntimeConnection {
1412 fn handle(&self) -> &RuntimeHandle {
1413 &self.handle
1414 }
1415
1416 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
1417 let mut parts = Vec::new();
1418 if !input.text.is_empty() {
1419 parts.push(json!({"type": "text", "text": input.text}));
1420 }
1421 for url in input.image_urls {
1422 let mime = image_mime_type(&url).ok_or_else(|| {
1423 Error::Other("OpenCode image prompts require a recognizable image MIME type".into())
1424 })?;
1425 parts.push(json!({"type":"file", "mime":mime, "url":url}));
1426 }
1427 http_ok(
1428 self.client
1429 .post(format!(
1430 "{}/session/{}/prompt_async",
1431 self.base_url, self.handle.runtime_id
1432 ))
1433 .query(&[("directory", &self.cwd)])
1434 .json(&json!({"parts": parts}))
1435 .send()
1436 .await,
1437 )
1438 .await?;
1439 Ok(None)
1440 }
1441
1442 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1443 loop {
1444 let Some(payload) = self.receiver.recv().await else {
1445 return Ok(None);
1446 };
1447 if opencode_event_session_id(&payload)
1448 .is_some_and(|session_id| session_id != self.handle.runtime_id)
1449 {
1450 continue;
1451 }
1452 let kind = payload
1453 .get("type")
1454 .and_then(Value::as_str)
1455 .unwrap_or("event")
1456 .to_string();
1457 return Ok(Some(HarnessEvent {
1458 sequence: None,
1459 kind,
1460 payload,
1461 }));
1462 }
1463 }
1464
1465 async fn interrupt(&mut self) -> Result<()> {
1466 http_ok(
1467 self.client
1468 .post(format!(
1469 "{}/session/{}/abort",
1470 self.base_url, self.handle.runtime_id
1471 ))
1472 .query(&[("directory", &self.cwd)])
1473 .send()
1474 .await,
1475 )
1476 .await?;
1477 Ok(())
1478 }
1479
1480 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1481 let permission = request_id.as_str().ok_or_else(|| {
1482 Error::Other("OpenCode permission request id must be a string".into())
1483 })?;
1484 http_ok(
1485 self.client
1486 .post(format!(
1487 "{}/session/{}/permissions/{permission}",
1488 self.base_url, self.handle.runtime_id
1489 ))
1490 .query(&[("directory", &self.cwd)])
1491 .json(&response)
1492 .send()
1493 .await,
1494 )
1495 .await?;
1496 Ok(())
1497 }
1498
1499 async fn close(&mut self) -> Result<()> {
1500 if let Some(child) = &mut self.child {
1501 terminate_opencode_server(child).await?;
1502 }
1503 Ok(())
1504 }
1505}
1506
1507fn data_image_parts(url: &str) -> Option<(&str, &str)> {
1508 let rest = url.strip_prefix("data:")?;
1509 let (mime_type, data) = rest.split_once(";base64,")?;
1510 mime_type.starts_with("image/").then_some((mime_type, data))
1511}
1512
1513fn image_mime_type(url: &str) -> Option<&str> {
1514 if let Some((mime_type, _)) = data_image_parts(url) {
1515 return Some(mime_type);
1516 }
1517 let path = url.split(['?', '#']).next()?.to_ascii_lowercase();
1518 if path.ends_with(".png") {
1519 Some("image/png")
1520 } else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
1521 Some("image/jpeg")
1522 } else if path.ends_with(".gif") {
1523 Some("image/gif")
1524 } else if path.ends_with(".webp") {
1525 Some("image/webp")
1526 } else {
1527 None
1528 }
1529}
1530
1531fn claude_image_part(url: &str) -> Result<Value> {
1532 if let Some((media_type, data)) = data_image_parts(url) {
1533 return Ok(json!({
1534 "type":"image",
1535 "source":{"type":"base64", "media_type":media_type, "data":data}
1536 }));
1537 }
1538 if url.starts_with("https://") || url.starts_with("http://") {
1539 return Ok(json!({"type":"image", "source":{"type":"url", "url":url}}));
1540 }
1541 Err(Error::Other(
1542 "Claude image prompts require image data URLs or HTTP(S) URLs".into(),
1543 ))
1544}
1545
1546fn opencode_event_session_id(payload: &Value) -> Option<&str> {
1547 let properties = payload.get("properties").unwrap_or(payload);
1548 properties
1549 .get("sessionID")
1550 .and_then(Value::as_str)
1551 .or_else(|| {
1552 properties
1553 .get("part")
1554 .and_then(|part| part.get("sessionID"))
1555 .and_then(Value::as_str)
1556 })
1557 .or_else(|| {
1558 properties
1559 .get("info")
1560 .and_then(|info| info.get("sessionID"))
1561 .and_then(Value::as_str)
1562 })
1563}
1564
1565async fn terminate_opencode_server(child: &mut Child) -> Result<()> {
1566 #[cfg(unix)]
1567 let process_group = child.id();
1568 let leader_exited = child.try_wait()?.is_some();
1569 if leader_exited {
1570 #[cfg(unix)]
1571 if let Some(pid) = process_group.filter(|pid| process_group_exists(*pid)) {
1572 crate::lsp::kill_process_group(pid);
1573 wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1574 }
1575 return Ok(());
1576 }
1577 #[cfg(unix)]
1583 if let Some(pid) = process_group {
1584 unsafe {
1585 libc::kill(-(pid as libc::pid_t), libc::SIGTERM);
1586 }
1587 let mut leader_reaped = false;
1588 if let Ok(status) = tokio::time::timeout(Duration::from_millis(500), child.wait()).await {
1589 status?;
1590 leader_reaped = true;
1591 if !process_group_exists(pid) {
1592 return Ok(());
1593 }
1594 }
1595 crate::lsp::kill_process_group(pid);
1598 if leader_reaped {
1599 return wait_for_process_group_exit(pid, Duration::from_secs(3)).await;
1600 }
1601 }
1602 #[cfg(not(unix))]
1603 child.start_kill()?;
1604 tokio::time::timeout(Duration::from_secs(3), child.wait())
1605 .await
1606 .map_err(|_| Error::Other("timed out reaping the OpenCode server".into()))??;
1607 #[cfg(unix)]
1608 if let Some(pid) = process_group {
1609 wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1610 }
1611 Ok(())
1612}
1613
1614#[cfg(unix)]
1615fn process_group_exists(pid: u32) -> bool {
1616 let result = unsafe { libc::kill(-(pid as libc::pid_t), 0) };
1617 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1618}
1619
1620#[cfg(unix)]
1621async fn wait_for_process_group_exit(pid: u32, timeout: Duration) -> Result<()> {
1622 let deadline = tokio::time::Instant::now() + timeout;
1623 while process_group_exists(pid) {
1624 if tokio::time::Instant::now() >= deadline {
1625 return Err(Error::Other(format!(
1626 "timed out stopping OpenCode process group {pid}"
1627 )));
1628 }
1629 tokio::time::sleep(Duration::from_millis(10)).await;
1630 }
1631 Ok(())
1632}
1633
1634struct RawLineTransport {
1635 stdin: Mutex<ChildStdin>,
1636 child: Mutex<super::GroupLeader>,
1637 receiver: mpsc::UnboundedReceiver<Value>,
1638 endpoint: RuntimeEndpoint,
1639}
1640
1641impl RawLineTransport {
1642 async fn spawn(launch: &RuntimeLaunch, cwd: Option<&Path>, protocol: &str) -> Result<Self> {
1643 let mut command = Command::new(&launch.program);
1644 command
1645 .args(&launch.arguments)
1646 .envs(&launch.env)
1647 .stdin(Stdio::piped())
1648 .stdout(Stdio::piped())
1649 .stderr(Stdio::inherit())
1650 .kill_on_drop(true);
1651 #[cfg(unix)]
1655 command.process_group(0);
1656 if let Some(cwd) = cwd {
1657 command.current_dir(cwd);
1658 }
1659 let mut child = command.spawn().map_err(|error| {
1660 Error::Other(format!("could not launch {}: {error}", launch.program))
1661 })?;
1662 let pid = child.id();
1663 let stdin = child
1664 .stdin
1665 .take()
1666 .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
1667 let stdout = child
1668 .stdout
1669 .take()
1670 .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
1671 let (sender, receiver) = mpsc::unbounded_channel();
1672 tokio::spawn(async move {
1673 let mut lines = BufReader::new(stdout).lines();
1674 while let Ok(Some(line)) = lines.next_line().await {
1675 let value = serde_json::from_str(&line)
1676 .unwrap_or_else(|_| json!({"type": "malformed_output", "line": line}));
1677 let _ = sender.send(value);
1678 }
1679 });
1680 Ok(Self {
1681 stdin: Mutex::new(stdin),
1682 child: Mutex::new(super::GroupLeader(child)),
1683 receiver,
1684 endpoint: RuntimeEndpoint::LocalProcess {
1685 pid,
1686 command: std::iter::once(launch.program.clone())
1687 .chain(launch.arguments.iter().cloned())
1688 .collect(),
1689 protocol: protocol.into(),
1690 },
1691 })
1692 }
1693
1694 async fn write(&self, value: Value) -> Result<()> {
1695 let mut stdin = self.stdin.lock().await;
1696 stdin.write_all(value.to_string().as_bytes()).await?;
1697 stdin.write_all(b"\n").await?;
1698 stdin.flush().await?;
1699 Ok(())
1700 }
1701
1702 async fn close(&self) -> Result<()> {
1703 let mut child = self.child.lock().await;
1704 if child.try_wait()?.is_some() {
1705 return Ok(());
1706 }
1707 #[cfg(unix)]
1710 if let Some(pid) = child.id() {
1711 crate::lsp::kill_process_group(pid);
1712 tokio::time::timeout(Duration::from_secs(3), child.wait())
1713 .await
1714 .map_err(|_| Error::Other("timed out reaping runtime process group".into()))??;
1715 return Ok(());
1716 }
1717 #[cfg(not(unix))]
1718 child.kill().await?;
1719 Ok(())
1720 }
1721}
1722
1723async fn raw_next_event(
1724 receiver: &mut mpsc::UnboundedReceiver<Value>,
1725) -> Result<Option<HarnessEvent>> {
1726 let Some(payload) = receiver.recv().await else {
1727 return Ok(None);
1728 };
1729 Ok(Some(harness_event(payload)))
1730}
1731
1732fn harness_event(payload: Value) -> HarnessEvent {
1733 let kind = payload
1734 .get("type")
1735 .and_then(Value::as_str)
1736 .unwrap_or("event")
1737 .to_string();
1738 HarnessEvent {
1739 sequence: None,
1740 kind,
1741 payload,
1742 }
1743}
1744
1745fn broken_pipe(error: &Error) -> bool {
1752 matches!(error, Error::Io(io) if io.kind() == std::io::ErrorKind::BrokenPipe)
1753}
1754
1755fn claude_interrupt_timeout(bound: Duration) -> Error {
1756 Error::Other(format!(
1757 "Claude Code did not acknowledge the interrupt control request within {}s",
1758 bound.as_secs_f32()
1759 ))
1760}
1761
1762pub(crate) fn generated_session_id() -> String {
1763 let mut bytes = [0_u8; 16];
1764 if getrandom::getrandom(&mut bytes).is_err() {
1765 let nanos = SystemTime::now()
1766 .duration_since(UNIX_EPOCH)
1767 .unwrap_or_default()
1768 .as_nanos()
1769 .to_le_bytes();
1770 bytes.copy_from_slice(&nanos);
1771 }
1772 bytes[6] = (bytes[6] & 0x0f) | 0x40;
1773 bytes[8] = (bytes[8] & 0x3f) | 0x80;
1774 format!(
1775 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
1776 bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
1777 bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
1778 )
1779}
1780
1781async fn wait_for_health(client: &reqwest::Client, base_url: &str) -> Result<()> {
1782 wait_for_health_for(client, base_url, Duration::from_secs(10)).await
1783}
1784
1785async fn wait_for_health_for(
1786 client: &reqwest::Client,
1787 base_url: &str,
1788 total_timeout: Duration,
1789) -> Result<()> {
1790 let url = format!("{base_url}/global/health");
1791 let mut last = None;
1792 let deadline = tokio::time::Instant::now() + total_timeout;
1793 loop {
1798 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1799 if remaining.is_zero() {
1800 break;
1801 }
1802 let request_timeout = remaining.min(Duration::from_millis(500));
1803 match tokio::time::timeout(request_timeout, client.get(&url).send()).await {
1804 Ok(Ok(response)) if response.status().is_success() => return Ok(()),
1805 Ok(Ok(response)) => last = Some(format!("HTTP {}", response.status())),
1806 Ok(Err(error)) => last = Some(error.to_string()),
1807 Err(_) => last = Some("health request timed out".into()),
1808 }
1809 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1810 if !remaining.is_zero() {
1811 tokio::time::sleep(remaining.min(Duration::from_millis(100))).await;
1812 }
1813 }
1814 Err(Error::Other(format!(
1815 "OpenCode server at {base_url} did not become healthy: {}",
1816 last.unwrap_or_else(|| "no response".into())
1817 )))
1818}
1819
1820async fn http_ok(
1821 response: std::result::Result<reqwest::Response, reqwest::Error>,
1822) -> Result<reqwest::Response> {
1823 response
1824 .map_err(http_error)?
1825 .error_for_status()
1826 .map_err(http_error)
1827}
1828
1829fn http_error(error: reqwest::Error) -> Error {
1830 Error::Other(format!("runtime HTTP request failed: {error}"))
1831}
1832
1833fn spawn_sse(
1834 client: reqwest::Client,
1835 url: String,
1836 directory: String,
1837) -> mpsc::UnboundedReceiver<Value> {
1838 let (sender, receiver) = mpsc::unbounded_channel();
1839 tokio::spawn(async move {
1840 let response = client
1841 .get(url)
1842 .query(&[("directory", directory)])
1843 .send()
1844 .await;
1845 let Ok(response) = response.and_then(reqwest::Response::error_for_status) else {
1846 let _ = sender.send(
1847 json!({"type": "stream_error", "message": "could not open OpenCode SSE stream"}),
1848 );
1849 return;
1850 };
1851 let mut stream = response.bytes_stream();
1852 let mut buffer = String::new();
1853 while let Some(chunk) = stream.next().await {
1854 let Ok(chunk) = chunk else {
1855 break;
1856 };
1857 buffer.push_str(&String::from_utf8_lossy(&chunk));
1858 while let Some(newline) = buffer.find('\n') {
1859 let line = buffer[..newline].trim_end_matches('\r').to_string();
1860 buffer.drain(..=newline);
1861 if let Some(data) = line.strip_prefix("data:") {
1862 let data = data.trim();
1863 if let Ok(value) = serde_json::from_str(data) {
1864 let _ = sender.send(value);
1865 }
1866 }
1867 }
1868 }
1869 });
1870 receiver
1871}
1872
1873#[cfg(test)]
1874mod tests {
1875 use super::*;
1876
1877 #[cfg(unix)]
1881 const FAKE_CLAUDE_ACKS: &str = r#"
1882cap="$1"
1883while IFS= read -r line; do
1884 printf '%s\n' "$line" >> "$cap"
1885 case "$line" in
1886 *'"subtype":"interrupt"'*)
1887 rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1888 printf '{"type":"system","subtype":"mid_flight"}\n'
1889 printf '{"type":"control_response","response":{"subtype":"success","request_id":"someone-elses-request","response":{}}}\n'
1890 printf '{"type":"control_response","response":{"subtype":"success","request_id":"%s","response":{"still_queued":[]}}}\n' "$rid"
1891 ;;
1892 *'"type":"user"'*)
1893 printf '{"type":"assistant","message":{"role":"assistant","content":"replied"}}\n'
1894 ;;
1895 esac
1896done
1897"#;
1898
1899 #[cfg(unix)]
1902 const FAKE_CLAUDE_NEVER_ACKS: &str = r#"
1903cap="$1"
1904while IFS= read -r line; do
1905 printf '%s\n' "$line" >> "$cap"
1906done
1907"#;
1908
1909 #[cfg(unix)]
1916 const FAKE_CLAUDE_ASKS_PERMISSION: &str = r#"
1917cap="$1"
1918printf '{"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'
1919while IFS= read -r line; do
1920 printf '%s\n' "$line" >> "$cap"
1921 case "$line" in
1922 *'"request_id":"053f8a2d-3445-4011-a259-4261b31c7326"'*)
1923 case "$line" in
1924 *'"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' ;;
1925 *) printf '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_mock_1","content":"denied","is_error":true}]}}\n' ;;
1926 esac
1927 ;;
1928 esac
1929done
1930"#;
1931
1932 #[cfg(unix)]
1934 const FAKE_CLAUDE_REJECTS: &str = r#"
1935cap="$1"
1936while IFS= read -r line; do
1937 printf '%s\n' "$line" >> "$cap"
1938 case "$line" in
1939 *'"subtype":"interrupt"'*)
1940 rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1941 printf '{"type":"control_response","response":{"subtype":"error","request_id":"%s","error":"no active worker"}}\n' "$rid"
1942 ;;
1943 esac
1944done
1945"#;
1946
1947 #[cfg(unix)]
1948 struct FakeClaude {
1949 connection: ClaudeRuntimeConnection,
1950 capture: std::path::PathBuf,
1951 _dir: std::path::PathBuf,
1952 }
1953
1954 #[cfg(unix)]
1955 impl FakeClaude {
1956 async fn spawn(script: &str, control_timeout: Duration) -> Self {
1957 Self::spawn_with(script, control_timeout, CLAUDE_PERMISSION_RESPONSE_TIMEOUT).await
1958 }
1959
1960 async fn spawn_with(
1961 script: &str,
1962 control_timeout: Duration,
1963 permission_timeout: Duration,
1964 ) -> Self {
1965 let dir = std::env::temp_dir().join(format!(
1966 "supercode-fake-claude-{}-{}",
1967 std::process::id(),
1968 generated_session_id()
1969 ));
1970 std::fs::create_dir_all(&dir).unwrap();
1971 let capture = dir.join("stdin.jsonl");
1972 let launch = RuntimeLaunch {
1973 program: "/bin/sh".into(),
1974 arguments: vec![
1975 "-c".into(),
1976 script.into(),
1977 "fake-claude".into(),
1978 capture.display().to_string(),
1979 ],
1980 env: BTreeMap::new(),
1981 };
1982 let transport = RawLineTransport::spawn(&launch, Some(&dir), "claude-stream-json")
1983 .await
1984 .unwrap();
1985 let connection = ClaudeRuntimeConnection {
1986 handle: RuntimeHandle {
1987 harness: HarnessId::from(HarnessId::CLAUDE_CODE),
1988 runtime_id: "fake-session".into(),
1989 endpoint: transport.endpoint.clone(),
1990 },
1991 transport,
1992 prefix: launch.clone(),
1993 cwd: dir.clone(),
1994 spoke: false,
1995 buffered_events: VecDeque::new(),
1996 next_control_request: 1,
1997 control_timeout,
1998 pending_permissions: Vec::new(),
1999 permission_timeout,
2000 };
2001 Self {
2002 connection,
2003 capture,
2004 _dir: dir,
2005 }
2006 }
2007
2008 fn written_frames(&self) -> Vec<Value> {
2009 std::fs::read_to_string(&self.capture)
2010 .unwrap_or_default()
2011 .lines()
2012 .filter(|line| !line.trim().is_empty())
2013 .map(|line| serde_json::from_str(line).expect("adapter wrote a non-JSON frame"))
2014 .collect()
2015 }
2016 }
2017
2018 #[cfg(unix)]
2019 #[tokio::test]
2020 async fn claude_interrupt_writes_one_control_request_per_call_with_a_fresh_id() {
2021 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
2022
2023 fake.connection.interrupt().await.unwrap();
2024 fake.connection.interrupt().await.unwrap();
2025
2026 let frames = fake.written_frames();
2027 assert_eq!(
2028 frames.len(),
2029 2,
2030 "each interrupt must write exactly one control frame: {frames:?}"
2031 );
2032 let mut ids = Vec::new();
2033 for frame in &frames {
2034 assert_eq!(frame["type"], "control_request");
2035 assert_eq!(frame["request"]["subtype"], "interrupt");
2036 let id = frame["request_id"].as_str().expect("frame carries an id");
2037 assert!(!id.is_empty());
2038 ids.push(id.to_string());
2039 }
2040 assert_ne!(ids[0], ids[1], "request ids must be unique per call");
2041 }
2042
2043 #[cfg(unix)]
2048 #[tokio::test]
2049 async fn claude_interrupt_with_no_turn_in_flight_is_acknowledged_and_the_session_survives() {
2050 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
2051
2052 fake.connection.interrupt().await.unwrap();
2053 fake.connection
2054 .send_input(RuntimeInput {
2055 text: String::new(),
2056 image_urls: vec!["data:image/png;base64,aGVsbG8=".into()],
2057 })
2058 .await
2059 .unwrap();
2060
2061 let mut kinds = Vec::new();
2066 while kinds.len() < 2 {
2067 let event = fake.connection.next_event().await.unwrap().unwrap();
2068 assert_ne!(event.kind, "control_response");
2069 kinds.push(event.kind);
2070 }
2071 assert_eq!(kinds, vec!["system".to_string(), "assistant".to_string()]);
2072
2073 let frames = fake.written_frames();
2074 assert_eq!(frames[0]["type"], "control_request");
2075 assert_eq!(
2076 frames[1]["type"], "user",
2077 "a send issued after an interrupt must reach the harness, in order"
2078 );
2079 assert_eq!(
2080 frames[1]["message"]["content"][0]["source"],
2081 json!({"type":"base64", "media_type":"image/png", "data":"aGVsbG8="}),
2082 "an image-only turn must remain native without a synthetic text block"
2083 );
2084 }
2085
2086 #[cfg(unix)]
2087 #[tokio::test]
2088 async fn claude_interrupt_times_out_with_a_structured_error_instead_of_hanging() {
2089 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_NEVER_ACKS, Duration::from_millis(250)).await;
2090
2091 let started = std::time::Instant::now();
2092 let error = fake.connection.interrupt().await.unwrap_err();
2093
2094 assert!(
2095 started.elapsed() < Duration::from_secs(5),
2096 "interrupt must return on its own bound, not hang"
2097 );
2098 assert!(
2099 error
2100 .to_string()
2101 .contains("did not acknowledge the interrupt"),
2102 "unexpected error: {error}"
2103 );
2104 assert_eq!(fake.written_frames().len(), 1);
2105 }
2106
2107 #[cfg(unix)]
2108 #[tokio::test]
2109 async fn claude_interrupt_surfaces_a_rejecting_control_response_as_an_error() {
2110 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_REJECTS, Duration::from_secs(5)).await;
2111
2112 let error = fake.connection.interrupt().await.unwrap_err();
2113
2114 assert!(
2115 error.to_string().contains("no active worker"),
2116 "unexpected error: {error}"
2117 );
2118 }
2119
2120 #[test]
2121 fn claude_code_runtime_advertises_mid_turn_controls() {
2122 let capabilities = ClaudeCodeRuntimeBackend::new().capabilities();
2123 assert!(capabilities.interrupt);
2124 assert!(capabilities.steer);
2125 assert!(capabilities.respond_to_requests);
2126 }
2127
2128 #[test]
2134 fn claude_code_launches_as_the_cli_permission_handler() {
2135 let backend = ClaudeCodeRuntimeBackend::new();
2136 let arguments = backend.launch.arguments.join(" ");
2137 assert!(
2138 arguments.contains("--permission-prompt-tool stdio"),
2139 "the default launch must register supercode as the permission handler: {arguments}"
2140 );
2141 assert!(arguments.contains("--input-format stream-json"));
2142 assert!(arguments.contains("--output-format stream-json"));
2143 }
2144
2145 #[cfg(unix)]
2151 #[tokio::test]
2152 async fn claude_permission_request_surfaces_and_respond_allows_the_blocked_tool() {
2153 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2154
2155 let request = fake.connection.next_event().await.unwrap().unwrap();
2156 assert_eq!(request.kind, "control_request");
2157 assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
2158 let request_id = request.payload["request_id"].clone();
2159
2160 fake.connection
2161 .respond(request_id.clone(), json!({"behavior": "allow"}))
2162 .await
2163 .unwrap();
2164
2165 let result = fake.connection.next_event().await.unwrap().unwrap();
2166 assert_eq!(result.kind, "user");
2167 assert_eq!(
2168 result.payload["message"]["content"][0]["is_error"],
2169 json!(false),
2170 "the allowed tool must have run: {}",
2171 result.payload
2172 );
2173
2174 let frames = fake.written_frames();
2175 assert_eq!(frames.len(), 1, "one answer per request: {frames:?}");
2176 assert_eq!(
2177 frames[0],
2178 json!({
2179 "type": "control_response",
2180 "response": {
2181 "subtype": "success",
2182 "request_id": request_id,
2183 "response": {"behavior": "allow"},
2184 },
2185 }),
2186 "the answer must be the envelope claude 2.1.258 accepts"
2187 );
2188 }
2189
2190 #[cfg(unix)]
2194 #[tokio::test]
2195 async fn claude_permission_deny_blocks_the_tool_and_always_carries_a_message() {
2196 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2197
2198 let request = fake.connection.next_event().await.unwrap().unwrap();
2199 fake.connection
2200 .respond(
2201 request.payload["request_id"].clone(),
2202 json!({"behavior": "deny"}),
2203 )
2204 .await
2205 .unwrap();
2206
2207 let result = fake.connection.next_event().await.unwrap().unwrap();
2208 assert_eq!(
2209 result.payload["message"]["content"][0]["is_error"],
2210 json!(true),
2211 "a denied tool must not run: {}",
2212 result.payload
2213 );
2214
2215 let frames = fake.written_frames();
2216 let message = frames[0]["response"]["response"]["message"]
2217 .as_str()
2218 .expect("deny must carry a message");
2219 assert!(!message.is_empty(), "{frames:?}");
2220 assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2221 }
2222
2223 #[cfg(unix)]
2227 #[tokio::test]
2228 async fn claude_permission_answers_outside_the_protocol_are_refused_by_name() {
2229 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2230 let request = fake.connection.next_event().await.unwrap().unwrap();
2231 let request_id = request.payload["request_id"].clone();
2232
2233 let error = fake
2234 .connection
2235 .respond(request_id.clone(), json!({"outcome": "selected"}))
2236 .await
2237 .unwrap_err();
2238 assert!(error.to_string().contains("`allow`"), "{error}");
2239 assert!(error.to_string().contains("`deny`"), "{error}");
2240
2241 let error = fake
2242 .connection
2243 .respond(json!("not-a-live-request"), json!({"behavior": "allow"}))
2244 .await
2245 .unwrap_err();
2246 assert!(error.to_string().contains("not-a-live-request"), "{error}");
2247
2248 assert!(fake.written_frames().is_empty());
2250 fake.connection
2251 .respond(request_id, json!({"behavior": "allow"}))
2252 .await
2253 .unwrap();
2254 let result = fake.connection.next_event().await.unwrap().unwrap();
2257 assert_eq!(result.payload["message"]["content"][0]["is_error"], false);
2258 assert_eq!(fake.written_frames().len(), 1);
2259 }
2260
2261 #[cfg(unix)]
2265 #[tokio::test]
2266 async fn an_unanswered_claude_permission_request_is_denied_on_the_adapter_bound() {
2267 let mut fake = FakeClaude::spawn_with(
2268 FAKE_CLAUDE_ASKS_PERMISSION,
2269 Duration::from_secs(5),
2270 Duration::from_millis(250),
2271 )
2272 .await;
2273
2274 let request = fake.connection.next_event().await.unwrap().unwrap();
2275 assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
2276
2277 let result = tokio::time::timeout(Duration::from_secs(5), fake.connection.next_event())
2278 .await
2279 .expect("the adapter must deny on its own bound rather than hang")
2280 .unwrap()
2281 .unwrap();
2282 assert_eq!(
2283 result.payload["message"]["content"][0]["is_error"],
2284 json!(true),
2285 "an unanswered request must deny: {}",
2286 result.payload
2287 );
2288
2289 let frames = fake.written_frames();
2290 assert_eq!(frames.len(), 1, "{frames:?}");
2291 assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2292 assert!(frames[0]["response"]["response"]["message"]
2293 .as_str()
2294 .unwrap()
2295 .contains("timeout"));
2296 }
2297
2298 #[test]
2299 fn capability_reports_distinguish_resume_from_process_attach() {
2300 assert!(
2301 !PiRuntimeBackend::new()
2302 .capabilities()
2303 .attach_existing_process
2304 );
2305 assert!(
2306 !ClaudeCodeRuntimeBackend::new()
2307 .capabilities()
2308 .attach_existing_process
2309 );
2310 assert!(
2311 !OpenCodeRuntimeBackend::new()
2312 .capabilities()
2313 .attach_existing_process
2314 );
2315 assert!(
2316 OpenCodeRuntimeBackend::connect("http://127.0.0.1:4096")
2317 .capabilities()
2318 .attach_existing_process
2319 );
2320 }
2321
2322 #[test]
2323 fn generated_ids_are_uuid_shaped_and_unique() {
2324 let first = generated_session_id();
2325 let second = generated_session_id();
2326 assert_eq!(first.len(), 36);
2327 assert_ne!(first, second);
2328 }
2329
2330 #[test]
2331 fn opencode_event_session_id_covers_current_event_shapes() {
2332 assert_eq!(
2333 opencode_event_session_id(&json!({
2334 "type": "session.status",
2335 "properties": {"sessionID": "session-direct", "status": {"type": "busy"}}
2336 })),
2337 Some("session-direct")
2338 );
2339 assert_eq!(
2340 opencode_event_session_id(&json!({
2341 "type": "message.part.updated",
2342 "properties": {"part": {"sessionID": "session-part", "type": "text"}}
2343 })),
2344 Some("session-part")
2345 );
2346 assert_eq!(
2347 opencode_event_session_id(&json!({
2348 "type": "message.updated",
2349 "properties": {"info": {"sessionID": "session-info", "role": "assistant"}}
2350 })),
2351 Some("session-info")
2352 );
2353 assert_eq!(
2354 opencode_event_session_id(&json!({"type": "server.connected"})),
2355 None
2356 );
2357 }
2358
2359 #[tokio::test]
2360 async fn opencode_runtime_skips_events_for_other_sessions() {
2361 let (sender, receiver) = mpsc::unbounded_channel();
2362 sender
2363 .send(json!({
2364 "type": "session.idle",
2365 "properties": {"sessionID": "foreign-session"}
2366 }))
2367 .unwrap();
2368 sender
2369 .send(json!({
2370 "type": "message.part.delta",
2371 "properties": {"sessionID": "local-session", "delta": "hello"}
2372 }))
2373 .unwrap();
2374 let mut connection = OpenCodeRuntimeConnection {
2375 handle: RuntimeHandle {
2376 harness: HarnessId::from(HarnessId::OPENCODE),
2377 runtime_id: "local-session".into(),
2378 endpoint: RuntimeEndpoint::Http {
2379 base_url: "http://127.0.0.1:1".into(),
2380 protocol: "opencode-http".into(),
2381 },
2382 },
2383 base_url: "http://127.0.0.1:1".into(),
2384 cwd: "/tmp".into(),
2385 client: reqwest::Client::new(),
2386 receiver,
2387 child: None,
2388 };
2389
2390 let event = connection.next_event().await.unwrap().unwrap();
2391
2392 assert_eq!(event.kind, "message.part.delta");
2393 assert_eq!(event.payload["properties"]["sessionID"], "local-session");
2394 }
2395
2396 #[cfg(unix)]
2397 #[tokio::test]
2398 async fn opencode_shutdown_reaps_a_launcher_process_group() {
2399 let mut command = Command::new("/bin/sh");
2400 command
2401 .args(["-c", "sleep 30 & wait"])
2402 .stdin(Stdio::null())
2403 .stdout(Stdio::null())
2404 .stderr(Stdio::null())
2405 .kill_on_drop(true)
2406 .process_group(0);
2407 let mut child = command.spawn().unwrap();
2408 let pid = child.id().unwrap();
2409
2410 terminate_opencode_server(&mut child).await.unwrap();
2411
2412 assert!(child.try_wait().unwrap().is_some());
2413 let group_still_exists = unsafe { libc::kill(-(pid as libc::pid_t), 0) } == 0;
2414 assert!(
2415 !group_still_exists,
2416 "OpenCode worker process group survived close"
2417 );
2418 }
2419
2420 #[cfg(unix)]
2421 #[tokio::test]
2422 async fn opencode_shutdown_reaps_workers_after_launcher_exit() {
2423 let mut command = Command::new("/bin/sh");
2424 command
2425 .args(["-c", "sleep 30 & exit 0"])
2426 .stdin(Stdio::null())
2427 .stdout(Stdio::null())
2428 .stderr(Stdio::null())
2429 .kill_on_drop(true)
2430 .process_group(0);
2431 let mut child = command.spawn().unwrap();
2432 let pid = child.id().unwrap();
2433 tokio::time::sleep(Duration::from_millis(200)).await;
2434
2435 terminate_opencode_server(&mut child).await.unwrap();
2436
2437 assert!(child.try_wait().unwrap().is_some());
2438 assert!(
2439 !process_group_exists(pid),
2440 "OpenCode worker process group survived its exited launcher"
2441 );
2442 }
2443
2444 #[tokio::test]
2445 async fn opencode_health_probe_is_bounded_when_a_socket_never_responds() {
2446 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2447 let address = listener.local_addr().unwrap();
2448 let server = tokio::spawn(async move {
2449 let (_socket, _) = listener.accept().await.unwrap();
2450 tokio::time::sleep(Duration::from_secs(30)).await;
2451 });
2452 let started = tokio::time::Instant::now();
2453
2454 let error = wait_for_health_for(
2455 &reqwest::Client::new(),
2456 &format!("http://{address}"),
2457 Duration::from_millis(200),
2458 )
2459 .await
2460 .unwrap_err();
2461
2462 assert!(error.to_string().contains("health request timed out"));
2463 assert!(started.elapsed() < Duration::from_secs(1));
2464 server.abort();
2465 }
2466
2467 #[cfg(unix)]
2468 #[tokio::test]
2469 async fn acp_adapter_negotiates_starts_and_streams_without_blocking_prompt() {
2470 let script = r#"
2471 i=0
2472 while IFS= read -r line; do
2473 i=$((i + 1))
2474 case "$i" in
2475 1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
2476 2) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"acp_mock"}}' ;;
2477 3)
2478 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp_mock","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}'
2479 printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2480 ;;
2481 esac
2482 done
2483 "#;
2484 let backend = AcpRuntimeBackend::new(
2485 HarnessId::from("mock-acp"),
2486 RuntimeLaunch {
2487 program: "/bin/sh".into(),
2488 arguments: vec!["-c".into(), script.into()],
2489 env: BTreeMap::new(),
2490 },
2491 );
2492 let mut connection = backend
2493 .start(RuntimeStartRequest {
2494 cwd: std::env::current_dir().unwrap(),
2495 launch: None,
2496 mcp_servers: Vec::new(),
2497 })
2498 .await
2499 .unwrap();
2500 assert_eq!(connection.handle().runtime_id, "acp_mock");
2501 assert_eq!(
2502 connection
2503 .send_input(RuntimeInput {
2504 text: "hi".into(),
2505 image_urls: Vec::new(),
2506 })
2507 .await
2508 .unwrap()
2509 .as_deref(),
2510 Some("3")
2511 );
2512 assert_eq!(
2513 connection.next_event().await.unwrap().unwrap().kind,
2514 "session/update"
2515 );
2516 assert_eq!(
2517 connection.next_event().await.unwrap().unwrap().kind,
2518 "supercode/acp_request_completed"
2519 );
2520 connection.close().await.unwrap();
2521 }
2522
2523 #[cfg(unix)]
2527 #[tokio::test]
2528 async fn acp_start_forwards_mcp_servers_into_session_new() {
2529 let capture = std::env::temp_dir().join(format!(
2530 "supercode-acp-mcp-{}-{}.json",
2531 std::process::id(),
2532 std::time::SystemTime::now()
2533 .duration_since(std::time::UNIX_EPOCH)
2534 .unwrap()
2535 .as_nanos()
2536 ));
2537 let script = format!(
2538 r#"
2539 i=0
2540 while IFS= read -r line; do
2541 i=$((i + 1))
2542 case "$i" in
2543 1) printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":1,"agentCapabilities":{{}},"authMethods":[]}}}}' ;;
2544 2)
2545 printf '%s\n' "$line" > {capture}
2546 printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"sessionId":"acp_mock"}}}}'
2547 ;;
2548 esac
2549 done
2550 "#,
2551 capture = capture.display()
2552 );
2553 let backend = AcpRuntimeBackend::new(
2554 HarnessId::from("mock-acp"),
2555 RuntimeLaunch {
2556 program: "/bin/sh".into(),
2557 arguments: vec!["-c".into(), script],
2558 env: BTreeMap::new(),
2559 },
2560 );
2561 let mut connection = backend
2562 .start(RuntimeStartRequest {
2563 cwd: std::env::current_dir().unwrap(),
2564 launch: None,
2565 mcp_servers: vec![McpServerLaunch {
2566 name: "orchestrator".into(),
2567 command: "/usr/bin/node".into(),
2568 arguments: vec!["/tmp/server.mjs".into()],
2569 env: BTreeMap::from([(
2570 "SUPERCODE_ORCHESTRATOR_PROFILE".into(),
2571 "coder".into(),
2572 )]),
2573 }],
2574 })
2575 .await
2576 .unwrap();
2577 connection.close().await.unwrap();
2578
2579 let sent: Value =
2580 serde_json::from_str(&std::fs::read_to_string(&capture).unwrap()).unwrap();
2581 let _ = std::fs::remove_file(&capture);
2582 assert_eq!(sent["method"], "session/new");
2583 assert_eq!(
2584 sent["params"]["mcpServers"],
2585 json!([{
2586 "name": "orchestrator",
2587 "command": "/usr/bin/node",
2588 "args": ["/tmp/server.mjs"],
2589 "env": [{"name": "SUPERCODE_ORCHESTRATOR_PROFILE", "value": "coder"}],
2590 }])
2591 );
2592 }
2593
2594 #[cfg(unix)]
2595 #[tokio::test]
2596 async fn acp_uses_an_existing_login_before_trying_an_advertised_auth_method() {
2597 let script = r#"
2598 i=0
2599 while IFS= read -r line; do
2600 i=$((i + 1))
2601 if [ "$i" -eq 1 ]; then
2602 printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[{"id":"cached_token"}]}}'
2603 elif printf '%s' "$line" | grep -q 'session/new'; then
2604 printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"existing_login"}}'
2605 else
2606 exit 9
2607 fi
2608 done
2609 "#;
2610 let backend = AcpRuntimeBackend::new(
2611 HarnessId::from("mock-acp"),
2612 RuntimeLaunch {
2613 program: "/bin/sh".into(),
2614 arguments: vec!["-c".into(), script.into()],
2615 env: BTreeMap::new(),
2616 },
2617 );
2618 let mut connection = backend
2619 .start(RuntimeStartRequest {
2620 cwd: std::env::current_dir().unwrap(),
2621 launch: None,
2622 mcp_servers: Vec::new(),
2623 })
2624 .await
2625 .unwrap();
2626 assert_eq!(connection.handle().runtime_id, "existing_login");
2627 connection.close().await.unwrap();
2628 }
2629
2630 #[cfg(unix)]
2631 #[tokio::test]
2632 async fn known_acp_agent_reports_and_uses_load_session_for_resume() {
2633 let script = r#"
2634 i=0
2635 while IFS= read -r line; do
2636 i=$((i + 1))
2637 case "$i" in
2638 1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true},"authMethods":[]}}' ;;
2639 2)
2640 case "$line" in
2641 *'"method":"session/load"'*'"sessionId":"existing-session"'*)
2642 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"historical replay"}}}}'
2643 printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{}}'
2644 ;;
2645 *) exit 42 ;;
2646 esac
2647 ;;
2648 3)
2649 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fresh output"}}}}'
2650 printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2651 ;;
2652 esac
2653 done
2654 "#;
2655 let backend = AcpRuntimeBackend::new(
2656 HarnessId::from("known-acp"),
2657 RuntimeLaunch {
2658 program: "/bin/sh".into(),
2659 arguments: vec!["-c".into(), script.into()],
2660 env: BTreeMap::new(),
2661 },
2662 )
2663 .with_resume_support(true);
2664 assert!(backend.capabilities().resume_session);
2665 let mut connection = backend
2666 .attach(RuntimeAttachRequest {
2667 runtime_id: "existing-session".into(),
2668 cwd: Some(std::env::current_dir().unwrap()),
2669 launch: None,
2670 })
2671 .await
2672 .unwrap();
2673 assert_eq!(connection.handle().runtime_id, "existing-session");
2674 assert_eq!(
2675 connection
2676 .send_input(RuntimeInput {
2677 text: "continue".into(),
2678 image_urls: Vec::new(),
2679 })
2680 .await
2681 .unwrap()
2682 .as_deref(),
2683 Some("3")
2684 );
2685 let event = connection.next_event().await.unwrap().unwrap();
2686 assert_eq!(event.kind, "session/update");
2687 assert_eq!(
2688 event
2689 .payload
2690 .pointer("/params/update/content/text")
2691 .and_then(Value::as_str),
2692 Some("fresh output")
2693 );
2694 assert_eq!(
2695 connection.next_event().await.unwrap().unwrap().kind,
2696 "supercode/acp_request_completed"
2697 );
2698 connection.close().await.unwrap();
2699 }
2700}