1use std::collections::{BTreeMap, VecDeque};
4use std::net::TcpListener;
5use std::path::Path;
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 HarnessEvent, JsonLineClient, RuntimeAttachRequest, RuntimeBackend, RuntimeCapabilities,
19 RuntimeConnection, RuntimeEndpoint, RuntimeHandle, RuntimeInput, RuntimeLaunch,
20 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)]
170pub struct ClaudeCodeRuntimeBackend {
171 launch: RuntimeLaunch,
172}
173
174impl Default for ClaudeCodeRuntimeBackend {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180impl ClaudeCodeRuntimeBackend {
181 pub fn new() -> Self {
183 Self {
184 launch: RuntimeLaunch {
185 program: "claude".into(),
186 arguments: vec![
187 "--print".into(),
188 "--input-format".into(),
189 "stream-json".into(),
190 "--output-format".into(),
191 "stream-json".into(),
192 "--verbose".into(),
193 ],
194 env: BTreeMap::new(),
195 },
196 }
197 }
198
199 pub fn with_launch(launch: RuntimeLaunch) -> Self {
201 Self { launch }
202 }
203
204 async fn open(
205 &self,
206 cwd: &Path,
207 runtime_id: String,
208 launch: Option<RuntimeLaunch>,
209 resume: bool,
210 ) -> Result<Box<dyn RuntimeConnection>> {
211 let mut launch = launch.unwrap_or_else(|| self.launch.clone());
212 launch.arguments.extend(if resume {
213 vec!["--resume".into(), runtime_id.clone()]
214 } else {
215 vec!["--session-id".into(), runtime_id.clone()]
216 });
217 let transport = RawLineTransport::spawn(&launch, Some(cwd), "claude-stream-json").await?;
218 Ok(Box::new(ClaudeRuntimeConnection {
219 handle: RuntimeHandle {
220 harness: HarnessId::from(HarnessId::CLAUDE_CODE),
221 runtime_id,
222 endpoint: transport.endpoint.clone(),
223 },
224 transport,
225 buffered_events: VecDeque::new(),
226 next_control_request: 1,
227 control_timeout: CLAUDE_CONTROL_RESPONSE_TIMEOUT,
228 }))
229 }
230}
231
232const CLAUDE_CONTROL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
242
243#[async_trait]
244impl RuntimeBackend for ClaudeCodeRuntimeBackend {
245 fn harness(&self) -> HarnessId {
246 HarnessId::from(HarnessId::CLAUDE_CODE)
247 }
248
249 fn capabilities(&self) -> RuntimeCapabilities {
250 RuntimeCapabilities {
251 start_session: true,
252 resume_session: true,
253 attach_existing_process: false,
254 send_input: true,
255 stream_events: true,
256 interrupt: true,
257 steer: true,
258 respond_to_requests: false,
259 }
260 }
261
262 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
263 self.open(&request.cwd, generated_session_id(), request.launch, false)
264 .await
265 }
266
267 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
268 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
269 self.open(&cwd, request.runtime_id, request.launch, true)
270 .await
271 }
272}
273
274struct ClaudeRuntimeConnection {
275 handle: RuntimeHandle,
276 transport: RawLineTransport,
277 buffered_events: VecDeque<Value>,
281 next_control_request: u64,
282 control_timeout: Duration,
283}
284
285impl ClaudeRuntimeConnection {
286 fn is_control_response(value: &Value) -> bool {
291 value.get("type").and_then(Value::as_str) == Some("control_response")
292 }
293
294 fn control_result(value: &Value, request_id: &str) -> Option<Result<()>> {
302 let response = value.get("response")?;
303 if response.get("request_id").and_then(Value::as_str) != Some(request_id) {
304 return None;
305 }
306 match response.get("subtype").and_then(Value::as_str) {
307 Some("success") => Some(Ok(())),
308 other => Some(Err(Error::Other(format!(
309 "Claude Code rejected the interrupt control request: {}",
310 response
311 .get("error")
312 .and_then(Value::as_str)
313 .map(str::to_string)
314 .unwrap_or_else(|| format!(
315 "control_response subtype {}",
316 other.unwrap_or("(missing)")
317 ))
318 )))),
319 }
320 }
321}
322
323#[async_trait]
324impl RuntimeConnection for ClaudeRuntimeConnection {
325 fn handle(&self) -> &RuntimeHandle {
326 &self.handle
327 }
328
329 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
330 let content = if input.image_urls.is_empty() {
331 Value::String(input.text)
332 } else {
333 let mut parts = Vec::new();
334 if !input.text.is_empty() {
335 parts.push(json!({"type":"text", "text":input.text}));
336 }
337 for url in input.image_urls {
338 parts.push(claude_image_part(&url)?);
339 }
340 Value::Array(parts)
341 };
342 self.transport
343 .write(json!({
344 "type": "user",
345 "session_id": self.handle.runtime_id,
346 "message": {"role": "user", "content": content},
347 }))
348 .await?;
349 Ok(None)
350 }
351
352 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
353 if let Some(payload) = self.buffered_events.pop_front() {
354 return Ok(Some(harness_event(payload)));
355 }
356 loop {
357 let Some(payload) = self.transport.receiver.recv().await else {
358 return Ok(None);
359 };
360 if Self::is_control_response(&payload) {
361 continue;
362 }
363 return Ok(Some(harness_event(payload)));
364 }
365 }
366
367 async fn interrupt(&mut self) -> Result<()> {
376 let request_id = format!(
377 "supercode-{}-interrupt-{}",
378 self.handle.runtime_id, self.next_control_request
379 );
380 self.next_control_request += 1;
381 self.transport
382 .write(json!({
383 "type": "control_request",
384 "request_id": request_id,
385 "request": {"subtype": "interrupt"},
386 }))
387 .await?;
388
389 let deadline = tokio::time::Instant::now() + self.control_timeout;
390 loop {
391 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
392 if remaining.is_zero() {
393 return Err(claude_interrupt_timeout(self.control_timeout));
394 }
395 match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
396 Err(_) => return Err(claude_interrupt_timeout(self.control_timeout)),
397 Ok(None) => return Err(Error::Other(
398 "Claude Code stream-json transport closed before acknowledging the interrupt"
399 .into(),
400 )),
401 Ok(Some(payload)) => {
402 if Self::is_control_response(&payload) {
403 if let Some(result) = Self::control_result(&payload, &request_id) {
404 return result;
405 }
406 continue;
407 }
408 self.buffered_events.push_back(payload);
409 }
410 }
411 }
412 }
413
414 async fn steer(&mut self, text: String) -> Result<()> {
415 self.send_input(RuntimeInput {
416 text,
417 image_urls: Vec::new(),
418 })
419 .await
420 .map(|_| ())
421 }
422
423 async fn respond(&mut self, _request_id: Value, _response: Value) -> Result<()> {
424 Err(unsupported(
425 "Claude Code stream-json",
426 "respond to protocol requests",
427 ))
428 }
429
430 async fn close(&mut self) -> Result<()> {
431 self.transport.close().await
432 }
433}
434
435#[derive(Debug, Clone)]
437pub struct AcpRuntimeBackend {
438 harness: HarnessId,
439 launch: RuntimeLaunch,
440 resume_session: bool,
441}
442
443impl AcpRuntimeBackend {
444 pub fn new(harness: HarnessId, launch: RuntimeLaunch) -> Self {
446 Self {
447 harness,
448 launch,
449 resume_session: false,
450 }
451 }
452
453 pub fn with_resume_support(mut self, supported: bool) -> Self {
457 self.resume_session = supported;
458 self
459 }
460
461 async fn connect(
462 &self,
463 cwd: &Path,
464 launch: Option<RuntimeLaunch>,
465 ) -> Result<(
466 Arc<JsonLineClient>,
467 mpsc::UnboundedReceiver<Value>,
468 RuntimeEndpoint,
469 Value,
470 )> {
471 let launch = launch.unwrap_or_else(|| self.launch.clone());
472 let (client, receiver, endpoint) =
473 JsonLineClient::spawn(&launch, Some(cwd), true, "acp-v1-jsonrpc").await?;
474 let initialized = client
475 .request(
476 "initialize",
477 json!({
478 "protocolVersion": 1,
479 "clientCapabilities": {},
480 "clientInfo": {
481 "name": "supercode",
482 "title": "Supercode",
483 "version": env!("CARGO_PKG_VERSION"),
484 },
485 }),
486 )
487 .await?;
488 if initialized.get("protocolVersion").and_then(Value::as_u64) != Some(1) {
489 return Err(Error::Other(format!(
490 "ACP agent negotiated unsupported protocol version: {}",
491 initialized
492 .get("protocolVersion")
493 .cloned()
494 .unwrap_or(Value::Null)
495 )));
496 }
497 Ok((client, receiver, endpoint, initialized))
498 }
499
500 async fn session_request(
501 &self,
502 client: &JsonLineClient,
503 initialized: &Value,
504 method: &str,
505 params: Value,
506 ) -> Result<Value> {
507 match client.request(method, params.clone()).await {
508 Ok(response) => Ok(response),
509 Err(error) if acp_auth_required(&error.to_string()) => {
510 let cached = initialized
511 .get("authMethods")
512 .and_then(Value::as_array)
513 .and_then(|methods| {
514 methods.iter().find_map(|candidate| {
515 (candidate.get("id").and_then(Value::as_str) == Some("cached_token"))
516 .then_some("cached_token")
517 })
518 });
519 let Some(method_id) = cached else {
520 return Err(Error::Other(
521 "ACP agent requires authentication but did not advertise the non-interactive `cached_token` method"
522 .into(),
523 ));
524 };
525 client
526 .request(
527 "authenticate",
528 json!({"methodId": method_id, "_meta": {"headless": true}}),
529 )
530 .await?;
531 client.request(method, params).await
532 }
533 Err(error) => Err(error),
534 }
535 }
536
537 async fn connection(
538 &self,
539 cwd: &Path,
540 runtime_id: Option<String>,
541 launch: Option<RuntimeLaunch>,
542 ) -> Result<Box<dyn RuntimeConnection>> {
543 let (client, mut receiver, endpoint, initialized) = self.connect(cwd, launch).await?;
544 let session_id = if let Some(session_id) = runtime_id {
545 let resume = initialized
546 .pointer("/agentCapabilities/sessionCapabilities/resume")
547 .is_some();
548 let load = initialized
549 .pointer("/agentCapabilities/loadSession")
550 .and_then(Value::as_bool)
551 .unwrap_or(false);
552 let method = if resume {
553 "session/resume"
554 } else if load {
555 "session/load"
556 } else {
557 return Err(Error::Other(
558 "ACP agent did not advertise session resume or load".into(),
559 ));
560 };
561 self.session_request(
562 client.as_ref(),
563 &initialized,
564 method,
565 json!({"sessionId": session_id, "cwd": cwd, "mcpServers": []}),
566 )
567 .await?;
568 session_id
569 } else {
570 self.session_request(
571 client.as_ref(),
572 &initialized,
573 "session/new",
574 json!({"cwd": cwd, "mcpServers": []}),
575 )
576 .await?
577 .get("sessionId")
578 .and_then(Value::as_str)
579 .ok_or_else(|| Error::Other("ACP session/new omitted sessionId".into()))?
580 .to_string()
581 };
582 while receiver.try_recv().is_ok() {}
591 Ok(Box::new(AcpRuntimeConnection {
592 handle: RuntimeHandle {
593 harness: self.harness.clone(),
594 runtime_id: session_id,
595 endpoint,
596 },
597 client,
598 receiver,
599 active_prompt: None,
600 }))
601 }
602}
603
604fn acp_auth_required(message: &str) -> bool {
605 let message = message.to_ascii_lowercase();
606 [
607 "auth",
608 "login",
609 "sign in",
610 "sign-in",
611 "unauthorized",
612 "forbidden",
613 "credential",
614 ]
615 .iter()
616 .any(|needle| message.contains(needle))
617}
618
619#[async_trait]
620impl RuntimeBackend for AcpRuntimeBackend {
621 fn harness(&self) -> HarnessId {
622 self.harness.clone()
623 }
624
625 fn capabilities(&self) -> RuntimeCapabilities {
626 RuntimeCapabilities {
627 start_session: true,
628 resume_session: self.resume_session,
631 attach_existing_process: false,
632 send_input: true,
633 stream_events: true,
634 interrupt: true,
635 steer: false,
636 respond_to_requests: true,
637 }
638 }
639
640 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
641 self.connection(&request.cwd, None, request.launch).await
642 }
643
644 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
645 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
646 self.connection(&cwd, Some(request.runtime_id), request.launch)
647 .await
648 }
649}
650
651struct AcpRuntimeConnection {
652 handle: RuntimeHandle,
653 client: Arc<JsonLineClient>,
654 receiver: mpsc::UnboundedReceiver<Value>,
655 active_prompt: Option<u64>,
656}
657
658#[async_trait]
659impl RuntimeConnection for AcpRuntimeConnection {
660 fn handle(&self) -> &RuntimeHandle {
661 &self.handle
662 }
663
664 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
665 let mut prompt = Vec::new();
666 if !input.text.is_empty() {
667 prompt.push(json!({"type": "text", "text": input.text}));
668 }
669 for url in input.image_urls {
670 let (mime_type, data) = data_image_parts(&url).ok_or_else(|| {
671 Error::Other("ACP image prompts require base64 image data URLs".into())
672 })?;
673 prompt.push(json!({"type":"image", "mimeType":mime_type, "data":data}));
674 }
675 let (id, response) = self
676 .client
677 .begin_request(
678 "session/prompt",
679 json!({
680 "sessionId": self.handle.runtime_id,
681 "prompt": prompt,
682 }),
683 )
684 .await?;
685 self.active_prompt = Some(id);
686 let client = self.client.clone();
687 tokio::spawn(async move {
688 let result = match response.await {
689 Ok(Ok(result)) => json!({"id": id, "result": result}),
690 Ok(Err(error)) => json!({"id": id, "error": error}),
691 Err(_) => json!({"id": id, "error": "response channel closed"}),
692 };
693 client.emit(json!({
694 "jsonrpc": "2.0",
695 "method": "supercode/acp_request_completed",
696 "params": result,
697 }));
698 });
699 Ok(Some(id.to_string()))
700 }
701
702 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
703 let Some(payload) = self.receiver.recv().await else {
704 return Ok(None);
705 };
706 let kind = payload
707 .get("method")
708 .and_then(Value::as_str)
709 .or_else(|| payload.get("type").and_then(Value::as_str))
710 .unwrap_or("protocol")
711 .to_string();
712 if kind == "supercode/acp_request_completed" {
713 self.active_prompt = None;
714 }
715 Ok(Some(HarnessEvent {
716 sequence: None,
717 kind,
718 payload,
719 }))
720 }
721
722 async fn interrupt(&mut self) -> Result<()> {
723 self.client
724 .notify(
725 "session/cancel",
726 json!({"sessionId": self.handle.runtime_id}),
727 )
728 .await
729 }
730
731 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
732 self.client.respond(request_id, response).await
733 }
734
735 async fn close(&mut self) -> Result<()> {
736 self.client.close().await
737 }
738}
739
740#[derive(Debug, Clone)]
744pub struct OpenCodeRuntimeBackend {
745 launch: RuntimeLaunch,
746 base_url: Option<String>,
747}
748
749impl Default for OpenCodeRuntimeBackend {
750 fn default() -> Self {
751 Self::new()
752 }
753}
754
755impl OpenCodeRuntimeBackend {
756 pub fn new() -> Self {
758 Self {
759 launch: RuntimeLaunch {
760 program: "opencode".into(),
761 arguments: vec!["serve".into()],
762 env: BTreeMap::new(),
763 },
764 base_url: None,
765 }
766 }
767
768 pub fn connect(base_url: impl Into<String>) -> Self {
771 Self {
772 base_url: Some(base_url.into().trim_end_matches('/').to_string()),
773 ..Self::new()
774 }
775 }
776
777 pub fn with_launch(mut self, launch: RuntimeLaunch) -> Self {
779 self.launch = launch;
780 self
781 }
782
783 async fn service(&self, launch: Option<RuntimeLaunch>) -> Result<(String, Option<Child>)> {
784 if let Some(base_url) = &self.base_url {
785 wait_for_health(base_url).await?;
786 return Ok((base_url.clone(), None));
787 }
788 let port = TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port();
789 let mut launch = launch.unwrap_or_else(|| self.launch.clone());
790 launch.arguments.extend([
791 "--hostname".into(),
792 "127.0.0.1".into(),
793 "--port".into(),
794 port.to_string(),
795 ]);
796 let mut command = Command::new(&launch.program);
797 command
798 .args(&launch.arguments)
799 .envs(&launch.env)
800 .stdin(Stdio::null())
801 .stdout(Stdio::null())
802 .stderr(Stdio::inherit())
803 .kill_on_drop(true);
804 #[cfg(unix)]
808 command.process_group(0);
809 let mut child = command.spawn().map_err(|error| {
810 Error::Other(format!("could not launch {}: {error}", launch.program))
811 })?;
812 let base_url = format!("http://127.0.0.1:{port}");
813 if let Err(error) = wait_for_health(&base_url).await {
814 let _ = terminate_opencode_server(&mut child).await;
818 return Err(error);
819 }
820 Ok((base_url, Some(child)))
821 }
822
823 async fn open(
824 &self,
825 cwd: &Path,
826 runtime_id: Option<String>,
827 launch: Option<RuntimeLaunch>,
828 ) -> Result<Box<dyn RuntimeConnection>> {
829 let (base_url, child) = self.service(launch).await?;
830 let client = reqwest::Client::new();
831 let cwd_string = cwd.to_string_lossy().to_string();
832 let runtime_id = match runtime_id {
833 Some(id) => {
834 http_ok(
835 client
836 .get(format!("{base_url}/session/{id}"))
837 .query(&[("directory", &cwd_string)])
838 .send()
839 .await,
840 )
841 .await?;
842 id
843 }
844 None => {
845 let response = http_ok(
846 client
847 .post(format!("{base_url}/session"))
848 .query(&[("directory", &cwd_string)])
849 .json(&json!({}))
850 .send()
851 .await,
852 )
853 .await?;
854 response
855 .json::<Value>()
856 .await
857 .map_err(http_error)?
858 .get("id")
859 .and_then(Value::as_str)
860 .ok_or_else(|| Error::Other("OpenCode create session omitted id".into()))?
861 .to_string()
862 }
863 };
864 let receiver = spawn_sse(
865 client.clone(),
866 format!("{base_url}/event"),
867 cwd_string.clone(),
868 );
869 Ok(Box::new(OpenCodeRuntimeConnection {
870 handle: RuntimeHandle {
871 harness: HarnessId::from(HarnessId::OPENCODE),
872 runtime_id,
873 endpoint: RuntimeEndpoint::Http {
874 base_url: base_url.clone(),
875 protocol: "opencode-http-sse".into(),
876 },
877 },
878 base_url,
879 cwd: cwd_string,
880 client,
881 receiver,
882 child,
883 }))
884 }
885}
886
887#[async_trait]
888impl RuntimeBackend for OpenCodeRuntimeBackend {
889 fn harness(&self) -> HarnessId {
890 HarnessId::from(HarnessId::OPENCODE)
891 }
892
893 fn capabilities(&self) -> RuntimeCapabilities {
894 RuntimeCapabilities {
895 start_session: true,
896 resume_session: true,
897 attach_existing_process: self.base_url.is_some(),
898 send_input: true,
899 stream_events: true,
900 interrupt: true,
901 steer: false,
902 respond_to_requests: true,
903 }
904 }
905
906 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
907 self.open(&request.cwd, None, request.launch).await
908 }
909
910 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
911 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
912 self.open(&cwd, Some(request.runtime_id), request.launch)
913 .await
914 }
915
916 async fn attach_existing(
917 &self,
918 request: RuntimeAttachRequest,
919 ) -> Result<Box<dyn RuntimeConnection>> {
920 if self.base_url.is_none() {
921 return Err(Error::Other(
922 "OpenCode live attach requires the existing server's `base_url`".into(),
923 ));
924 }
925 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
926 self.open(&cwd, Some(request.runtime_id), request.launch)
927 .await
928 }
929}
930
931struct OpenCodeRuntimeConnection {
932 handle: RuntimeHandle,
933 base_url: String,
934 cwd: String,
935 client: reqwest::Client,
936 receiver: mpsc::UnboundedReceiver<Value>,
937 child: Option<Child>,
938}
939
940#[async_trait]
941impl RuntimeConnection for OpenCodeRuntimeConnection {
942 fn handle(&self) -> &RuntimeHandle {
943 &self.handle
944 }
945
946 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
947 let mut parts = Vec::new();
948 if !input.text.is_empty() {
949 parts.push(json!({"type": "text", "text": input.text}));
950 }
951 for url in input.image_urls {
952 let mime = image_mime_type(&url).ok_or_else(|| {
953 Error::Other("OpenCode image prompts require a recognizable image MIME type".into())
954 })?;
955 parts.push(json!({"type":"file", "mime":mime, "url":url}));
956 }
957 http_ok(
958 self.client
959 .post(format!(
960 "{}/session/{}/prompt_async",
961 self.base_url, self.handle.runtime_id
962 ))
963 .query(&[("directory", &self.cwd)])
964 .json(&json!({"parts": parts}))
965 .send()
966 .await,
967 )
968 .await?;
969 Ok(None)
970 }
971
972 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
973 loop {
974 let Some(payload) = self.receiver.recv().await else {
975 return Ok(None);
976 };
977 if opencode_event_session_id(&payload)
978 .is_some_and(|session_id| session_id != self.handle.runtime_id)
979 {
980 continue;
981 }
982 let kind = payload
983 .get("type")
984 .and_then(Value::as_str)
985 .unwrap_or("event")
986 .to_string();
987 return Ok(Some(HarnessEvent {
988 sequence: None,
989 kind,
990 payload,
991 }));
992 }
993 }
994
995 async fn interrupt(&mut self) -> Result<()> {
996 http_ok(
997 self.client
998 .post(format!(
999 "{}/session/{}/abort",
1000 self.base_url, self.handle.runtime_id
1001 ))
1002 .query(&[("directory", &self.cwd)])
1003 .send()
1004 .await,
1005 )
1006 .await?;
1007 Ok(())
1008 }
1009
1010 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1011 let permission = request_id.as_str().ok_or_else(|| {
1012 Error::Other("OpenCode permission request id must be a string".into())
1013 })?;
1014 http_ok(
1015 self.client
1016 .post(format!(
1017 "{}/session/{}/permissions/{permission}",
1018 self.base_url, self.handle.runtime_id
1019 ))
1020 .query(&[("directory", &self.cwd)])
1021 .json(&response)
1022 .send()
1023 .await,
1024 )
1025 .await?;
1026 Ok(())
1027 }
1028
1029 async fn close(&mut self) -> Result<()> {
1030 if let Some(child) = &mut self.child {
1031 terminate_opencode_server(child).await?;
1032 }
1033 Ok(())
1034 }
1035}
1036
1037fn data_image_parts(url: &str) -> Option<(&str, &str)> {
1038 let rest = url.strip_prefix("data:")?;
1039 let (mime_type, data) = rest.split_once(";base64,")?;
1040 mime_type.starts_with("image/").then_some((mime_type, data))
1041}
1042
1043fn image_mime_type(url: &str) -> Option<&str> {
1044 if let Some((mime_type, _)) = data_image_parts(url) {
1045 return Some(mime_type);
1046 }
1047 let path = url.split(['?', '#']).next()?.to_ascii_lowercase();
1048 if path.ends_with(".png") {
1049 Some("image/png")
1050 } else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
1051 Some("image/jpeg")
1052 } else if path.ends_with(".gif") {
1053 Some("image/gif")
1054 } else if path.ends_with(".webp") {
1055 Some("image/webp")
1056 } else {
1057 None
1058 }
1059}
1060
1061fn claude_image_part(url: &str) -> Result<Value> {
1062 if let Some((media_type, data)) = data_image_parts(url) {
1063 return Ok(json!({
1064 "type":"image",
1065 "source":{"type":"base64", "media_type":media_type, "data":data}
1066 }));
1067 }
1068 if url.starts_with("https://") || url.starts_with("http://") {
1069 return Ok(json!({"type":"image", "source":{"type":"url", "url":url}}));
1070 }
1071 Err(Error::Other(
1072 "Claude image prompts require image data URLs or HTTP(S) URLs".into(),
1073 ))
1074}
1075
1076fn opencode_event_session_id(payload: &Value) -> Option<&str> {
1077 let properties = payload.get("properties").unwrap_or(payload);
1078 properties
1079 .get("sessionID")
1080 .and_then(Value::as_str)
1081 .or_else(|| {
1082 properties
1083 .get("part")
1084 .and_then(|part| part.get("sessionID"))
1085 .and_then(Value::as_str)
1086 })
1087 .or_else(|| {
1088 properties
1089 .get("info")
1090 .and_then(|info| info.get("sessionID"))
1091 .and_then(Value::as_str)
1092 })
1093}
1094
1095async fn terminate_opencode_server(child: &mut Child) -> Result<()> {
1096 #[cfg(unix)]
1097 let process_group = child.id();
1098 let leader_exited = child.try_wait()?.is_some();
1099 if leader_exited {
1100 #[cfg(unix)]
1101 if let Some(pid) = process_group.filter(|pid| process_group_exists(*pid)) {
1102 crate::lsp::kill_process_group(pid);
1103 wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1104 }
1105 return Ok(());
1106 }
1107 #[cfg(unix)]
1113 if let Some(pid) = process_group {
1114 unsafe {
1115 libc::kill(-(pid as libc::pid_t), libc::SIGTERM);
1116 }
1117 let mut leader_reaped = false;
1118 if let Ok(status) = tokio::time::timeout(Duration::from_millis(500), child.wait()).await {
1119 status?;
1120 leader_reaped = true;
1121 if !process_group_exists(pid) {
1122 return Ok(());
1123 }
1124 }
1125 crate::lsp::kill_process_group(pid);
1128 if leader_reaped {
1129 return wait_for_process_group_exit(pid, Duration::from_secs(3)).await;
1130 }
1131 }
1132 #[cfg(not(unix))]
1133 child.start_kill()?;
1134 tokio::time::timeout(Duration::from_secs(3), child.wait())
1135 .await
1136 .map_err(|_| Error::Other("timed out reaping the OpenCode server".into()))??;
1137 #[cfg(unix)]
1138 if let Some(pid) = process_group {
1139 wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1140 }
1141 Ok(())
1142}
1143
1144#[cfg(unix)]
1145fn process_group_exists(pid: u32) -> bool {
1146 let result = unsafe { libc::kill(-(pid as libc::pid_t), 0) };
1147 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1148}
1149
1150#[cfg(unix)]
1151async fn wait_for_process_group_exit(pid: u32, timeout: Duration) -> Result<()> {
1152 let deadline = tokio::time::Instant::now() + timeout;
1153 while process_group_exists(pid) {
1154 if tokio::time::Instant::now() >= deadline {
1155 return Err(Error::Other(format!(
1156 "timed out stopping OpenCode process group {pid}"
1157 )));
1158 }
1159 tokio::time::sleep(Duration::from_millis(10)).await;
1160 }
1161 Ok(())
1162}
1163
1164struct RawLineTransport {
1165 stdin: Mutex<ChildStdin>,
1166 child: Mutex<Child>,
1167 receiver: mpsc::UnboundedReceiver<Value>,
1168 endpoint: RuntimeEndpoint,
1169}
1170
1171impl RawLineTransport {
1172 async fn spawn(launch: &RuntimeLaunch, cwd: Option<&Path>, protocol: &str) -> Result<Self> {
1173 let mut command = Command::new(&launch.program);
1174 command
1175 .args(&launch.arguments)
1176 .envs(&launch.env)
1177 .stdin(Stdio::piped())
1178 .stdout(Stdio::piped())
1179 .stderr(Stdio::inherit())
1180 .kill_on_drop(true);
1181 if let Some(cwd) = cwd {
1182 command.current_dir(cwd);
1183 }
1184 let mut child = command.spawn().map_err(|error| {
1185 Error::Other(format!("could not launch {}: {error}", launch.program))
1186 })?;
1187 let pid = child.id();
1188 let stdin = child
1189 .stdin
1190 .take()
1191 .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
1192 let stdout = child
1193 .stdout
1194 .take()
1195 .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
1196 let (sender, receiver) = mpsc::unbounded_channel();
1197 tokio::spawn(async move {
1198 let mut lines = BufReader::new(stdout).lines();
1199 while let Ok(Some(line)) = lines.next_line().await {
1200 let value = serde_json::from_str(&line)
1201 .unwrap_or_else(|_| json!({"type": "malformed_output", "line": line}));
1202 let _ = sender.send(value);
1203 }
1204 });
1205 Ok(Self {
1206 stdin: Mutex::new(stdin),
1207 child: Mutex::new(child),
1208 receiver,
1209 endpoint: RuntimeEndpoint::LocalProcess {
1210 pid,
1211 command: std::iter::once(launch.program.clone())
1212 .chain(launch.arguments.iter().cloned())
1213 .collect(),
1214 protocol: protocol.into(),
1215 },
1216 })
1217 }
1218
1219 async fn write(&self, value: Value) -> Result<()> {
1220 let mut stdin = self.stdin.lock().await;
1221 stdin.write_all(value.to_string().as_bytes()).await?;
1222 stdin.write_all(b"\n").await?;
1223 stdin.flush().await?;
1224 Ok(())
1225 }
1226
1227 async fn close(&self) -> Result<()> {
1228 let mut child = self.child.lock().await;
1229 if child.try_wait()?.is_none() {
1230 child.kill().await?;
1231 }
1232 Ok(())
1233 }
1234}
1235
1236async fn raw_next_event(
1237 receiver: &mut mpsc::UnboundedReceiver<Value>,
1238) -> Result<Option<HarnessEvent>> {
1239 let Some(payload) = receiver.recv().await else {
1240 return Ok(None);
1241 };
1242 Ok(Some(harness_event(payload)))
1243}
1244
1245fn harness_event(payload: Value) -> HarnessEvent {
1246 let kind = payload
1247 .get("type")
1248 .and_then(Value::as_str)
1249 .unwrap_or("event")
1250 .to_string();
1251 HarnessEvent {
1252 sequence: None,
1253 kind,
1254 payload,
1255 }
1256}
1257
1258fn claude_interrupt_timeout(bound: Duration) -> Error {
1259 Error::Other(format!(
1260 "Claude Code did not acknowledge the interrupt control request within {}s",
1261 bound.as_secs_f32()
1262 ))
1263}
1264
1265pub(crate) fn generated_session_id() -> String {
1266 let mut bytes = [0_u8; 16];
1267 if getrandom::getrandom(&mut bytes).is_err() {
1268 let nanos = SystemTime::now()
1269 .duration_since(UNIX_EPOCH)
1270 .unwrap_or_default()
1271 .as_nanos()
1272 .to_le_bytes();
1273 bytes.copy_from_slice(&nanos);
1274 }
1275 bytes[6] = (bytes[6] & 0x0f) | 0x40;
1276 bytes[8] = (bytes[8] & 0x3f) | 0x80;
1277 format!(
1278 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
1279 bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
1280 bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
1281 )
1282}
1283
1284fn unsupported(protocol: &str, operation: &str) -> Error {
1285 Error::Other(format!("{protocol} does not support {operation}"))
1286}
1287
1288async fn wait_for_health(base_url: &str) -> Result<()> {
1289 wait_for_health_for(base_url, Duration::from_secs(10)).await
1290}
1291
1292async fn wait_for_health_for(base_url: &str, total_timeout: Duration) -> Result<()> {
1293 let client = reqwest::Client::new();
1294 let url = format!("{base_url}/global/health");
1295 let mut last = None;
1296 let deadline = tokio::time::Instant::now() + total_timeout;
1297 loop {
1302 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1303 if remaining.is_zero() {
1304 break;
1305 }
1306 let request_timeout = remaining.min(Duration::from_millis(500));
1307 match tokio::time::timeout(request_timeout, client.get(&url).send()).await {
1308 Ok(Ok(response)) if response.status().is_success() => return Ok(()),
1309 Ok(Ok(response)) => last = Some(format!("HTTP {}", response.status())),
1310 Ok(Err(error)) => last = Some(error.to_string()),
1311 Err(_) => last = Some("health request timed out".into()),
1312 }
1313 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1314 if !remaining.is_zero() {
1315 tokio::time::sleep(remaining.min(Duration::from_millis(100))).await;
1316 }
1317 }
1318 Err(Error::Other(format!(
1319 "OpenCode server at {base_url} did not become healthy: {}",
1320 last.unwrap_or_else(|| "no response".into())
1321 )))
1322}
1323
1324async fn http_ok(
1325 response: std::result::Result<reqwest::Response, reqwest::Error>,
1326) -> Result<reqwest::Response> {
1327 response
1328 .map_err(http_error)?
1329 .error_for_status()
1330 .map_err(http_error)
1331}
1332
1333fn http_error(error: reqwest::Error) -> Error {
1334 Error::Other(format!("runtime HTTP request failed: {error}"))
1335}
1336
1337fn spawn_sse(
1338 client: reqwest::Client,
1339 url: String,
1340 directory: String,
1341) -> mpsc::UnboundedReceiver<Value> {
1342 let (sender, receiver) = mpsc::unbounded_channel();
1343 tokio::spawn(async move {
1344 let response = client
1345 .get(url)
1346 .query(&[("directory", directory)])
1347 .send()
1348 .await;
1349 let Ok(response) = response.and_then(reqwest::Response::error_for_status) else {
1350 let _ = sender.send(
1351 json!({"type": "stream_error", "message": "could not open OpenCode SSE stream"}),
1352 );
1353 return;
1354 };
1355 let mut stream = response.bytes_stream();
1356 let mut buffer = String::new();
1357 while let Some(chunk) = stream.next().await {
1358 let Ok(chunk) = chunk else {
1359 break;
1360 };
1361 buffer.push_str(&String::from_utf8_lossy(&chunk));
1362 while let Some(newline) = buffer.find('\n') {
1363 let line = buffer[..newline].trim_end_matches('\r').to_string();
1364 buffer.drain(..=newline);
1365 if let Some(data) = line.strip_prefix("data:") {
1366 let data = data.trim();
1367 if let Ok(value) = serde_json::from_str(data) {
1368 let _ = sender.send(value);
1369 }
1370 }
1371 }
1372 }
1373 });
1374 receiver
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379 use super::*;
1380
1381 #[cfg(unix)]
1385 const FAKE_CLAUDE_ACKS: &str = r#"
1386cap="$1"
1387while IFS= read -r line; do
1388 printf '%s\n' "$line" >> "$cap"
1389 case "$line" in
1390 *'"subtype":"interrupt"'*)
1391 rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1392 printf '{"type":"system","subtype":"mid_flight"}\n'
1393 printf '{"type":"control_response","response":{"subtype":"success","request_id":"someone-elses-request","response":{}}}\n'
1394 printf '{"type":"control_response","response":{"subtype":"success","request_id":"%s","response":{"still_queued":[]}}}\n' "$rid"
1395 ;;
1396 *'"type":"user"'*)
1397 printf '{"type":"assistant","message":{"role":"assistant","content":"replied"}}\n'
1398 ;;
1399 esac
1400done
1401"#;
1402
1403 #[cfg(unix)]
1406 const FAKE_CLAUDE_NEVER_ACKS: &str = r#"
1407cap="$1"
1408while IFS= read -r line; do
1409 printf '%s\n' "$line" >> "$cap"
1410done
1411"#;
1412
1413 #[cfg(unix)]
1415 const FAKE_CLAUDE_REJECTS: &str = r#"
1416cap="$1"
1417while IFS= read -r line; do
1418 printf '%s\n' "$line" >> "$cap"
1419 case "$line" in
1420 *'"subtype":"interrupt"'*)
1421 rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1422 printf '{"type":"control_response","response":{"subtype":"error","request_id":"%s","error":"no active worker"}}\n' "$rid"
1423 ;;
1424 esac
1425done
1426"#;
1427
1428 #[cfg(unix)]
1429 struct FakeClaude {
1430 connection: ClaudeRuntimeConnection,
1431 capture: std::path::PathBuf,
1432 _dir: std::path::PathBuf,
1433 }
1434
1435 #[cfg(unix)]
1436 impl FakeClaude {
1437 async fn spawn(script: &str, control_timeout: Duration) -> Self {
1438 let dir = std::env::temp_dir().join(format!(
1439 "supercode-fake-claude-{}-{}",
1440 std::process::id(),
1441 generated_session_id()
1442 ));
1443 std::fs::create_dir_all(&dir).unwrap();
1444 let capture = dir.join("stdin.jsonl");
1445 let launch = RuntimeLaunch {
1446 program: "/bin/sh".into(),
1447 arguments: vec![
1448 "-c".into(),
1449 script.into(),
1450 "fake-claude".into(),
1451 capture.display().to_string(),
1452 ],
1453 env: BTreeMap::new(),
1454 };
1455 let transport = RawLineTransport::spawn(&launch, Some(&dir), "claude-stream-json")
1456 .await
1457 .unwrap();
1458 let connection = ClaudeRuntimeConnection {
1459 handle: RuntimeHandle {
1460 harness: HarnessId::from(HarnessId::CLAUDE_CODE),
1461 runtime_id: "fake-session".into(),
1462 endpoint: transport.endpoint.clone(),
1463 },
1464 transport,
1465 buffered_events: VecDeque::new(),
1466 next_control_request: 1,
1467 control_timeout,
1468 };
1469 Self {
1470 connection,
1471 capture,
1472 _dir: dir,
1473 }
1474 }
1475
1476 fn written_frames(&self) -> Vec<Value> {
1477 std::fs::read_to_string(&self.capture)
1478 .unwrap_or_default()
1479 .lines()
1480 .filter(|line| !line.trim().is_empty())
1481 .map(|line| serde_json::from_str(line).expect("adapter wrote a non-JSON frame"))
1482 .collect()
1483 }
1484 }
1485
1486 #[cfg(unix)]
1487 #[tokio::test]
1488 async fn claude_interrupt_writes_one_control_request_per_call_with_a_fresh_id() {
1489 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1490
1491 fake.connection.interrupt().await.unwrap();
1492 fake.connection.interrupt().await.unwrap();
1493
1494 let frames = fake.written_frames();
1495 assert_eq!(
1496 frames.len(),
1497 2,
1498 "each interrupt must write exactly one control frame: {frames:?}"
1499 );
1500 let mut ids = Vec::new();
1501 for frame in &frames {
1502 assert_eq!(frame["type"], "control_request");
1503 assert_eq!(frame["request"]["subtype"], "interrupt");
1504 let id = frame["request_id"].as_str().expect("frame carries an id");
1505 assert!(!id.is_empty());
1506 ids.push(id.to_string());
1507 }
1508 assert_ne!(ids[0], ids[1], "request ids must be unique per call");
1509 }
1510
1511 #[cfg(unix)]
1516 #[tokio::test]
1517 async fn claude_interrupt_with_no_turn_in_flight_is_acknowledged_and_the_session_survives() {
1518 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1519
1520 fake.connection.interrupt().await.unwrap();
1521 fake.connection
1522 .send_input(RuntimeInput {
1523 text: String::new(),
1524 image_urls: vec!["data:image/png;base64,aGVsbG8=".into()],
1525 })
1526 .await
1527 .unwrap();
1528
1529 let mut kinds = Vec::new();
1534 while kinds.len() < 2 {
1535 let event = fake.connection.next_event().await.unwrap().unwrap();
1536 assert_ne!(event.kind, "control_response");
1537 kinds.push(event.kind);
1538 }
1539 assert_eq!(kinds, vec!["system".to_string(), "assistant".to_string()]);
1540
1541 let frames = fake.written_frames();
1542 assert_eq!(frames[0]["type"], "control_request");
1543 assert_eq!(
1544 frames[1]["type"], "user",
1545 "a send issued after an interrupt must reach the harness, in order"
1546 );
1547 assert_eq!(
1548 frames[1]["message"]["content"][0]["source"],
1549 json!({"type":"base64", "media_type":"image/png", "data":"aGVsbG8="}),
1550 "an image-only turn must remain native without a synthetic text block"
1551 );
1552 }
1553
1554 #[cfg(unix)]
1555 #[tokio::test]
1556 async fn claude_interrupt_times_out_with_a_structured_error_instead_of_hanging() {
1557 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_NEVER_ACKS, Duration::from_millis(250)).await;
1558
1559 let started = std::time::Instant::now();
1560 let error = fake.connection.interrupt().await.unwrap_err();
1561
1562 assert!(
1563 started.elapsed() < Duration::from_secs(5),
1564 "interrupt must return on its own bound, not hang"
1565 );
1566 assert!(
1567 error
1568 .to_string()
1569 .contains("did not acknowledge the interrupt"),
1570 "unexpected error: {error}"
1571 );
1572 assert_eq!(fake.written_frames().len(), 1);
1573 }
1574
1575 #[cfg(unix)]
1576 #[tokio::test]
1577 async fn claude_interrupt_surfaces_a_rejecting_control_response_as_an_error() {
1578 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_REJECTS, Duration::from_secs(5)).await;
1579
1580 let error = fake.connection.interrupt().await.unwrap_err();
1581
1582 assert!(
1583 error.to_string().contains("no active worker"),
1584 "unexpected error: {error}"
1585 );
1586 }
1587
1588 #[test]
1589 fn claude_code_runtime_advertises_mid_turn_controls() {
1590 let capabilities = ClaudeCodeRuntimeBackend::new().capabilities();
1591 assert!(capabilities.interrupt);
1592 assert!(capabilities.steer);
1593 }
1594
1595 #[test]
1596 fn capability_reports_distinguish_resume_from_process_attach() {
1597 assert!(
1598 !PiRuntimeBackend::new()
1599 .capabilities()
1600 .attach_existing_process
1601 );
1602 assert!(
1603 !ClaudeCodeRuntimeBackend::new()
1604 .capabilities()
1605 .attach_existing_process
1606 );
1607 assert!(
1608 !OpenCodeRuntimeBackend::new()
1609 .capabilities()
1610 .attach_existing_process
1611 );
1612 assert!(
1613 OpenCodeRuntimeBackend::connect("http://127.0.0.1:4096")
1614 .capabilities()
1615 .attach_existing_process
1616 );
1617 }
1618
1619 #[test]
1620 fn generated_ids_are_uuid_shaped_and_unique() {
1621 let first = generated_session_id();
1622 let second = generated_session_id();
1623 assert_eq!(first.len(), 36);
1624 assert_ne!(first, second);
1625 }
1626
1627 #[test]
1628 fn opencode_event_session_id_covers_current_event_shapes() {
1629 assert_eq!(
1630 opencode_event_session_id(&json!({
1631 "type": "session.status",
1632 "properties": {"sessionID": "session-direct", "status": {"type": "busy"}}
1633 })),
1634 Some("session-direct")
1635 );
1636 assert_eq!(
1637 opencode_event_session_id(&json!({
1638 "type": "message.part.updated",
1639 "properties": {"part": {"sessionID": "session-part", "type": "text"}}
1640 })),
1641 Some("session-part")
1642 );
1643 assert_eq!(
1644 opencode_event_session_id(&json!({
1645 "type": "message.updated",
1646 "properties": {"info": {"sessionID": "session-info", "role": "assistant"}}
1647 })),
1648 Some("session-info")
1649 );
1650 assert_eq!(
1651 opencode_event_session_id(&json!({"type": "server.connected"})),
1652 None
1653 );
1654 }
1655
1656 #[tokio::test]
1657 async fn opencode_runtime_skips_events_for_other_sessions() {
1658 let (sender, receiver) = mpsc::unbounded_channel();
1659 sender
1660 .send(json!({
1661 "type": "session.idle",
1662 "properties": {"sessionID": "foreign-session"}
1663 }))
1664 .unwrap();
1665 sender
1666 .send(json!({
1667 "type": "message.part.delta",
1668 "properties": {"sessionID": "local-session", "delta": "hello"}
1669 }))
1670 .unwrap();
1671 let mut connection = OpenCodeRuntimeConnection {
1672 handle: RuntimeHandle {
1673 harness: HarnessId::from(HarnessId::OPENCODE),
1674 runtime_id: "local-session".into(),
1675 endpoint: RuntimeEndpoint::Http {
1676 base_url: "http://127.0.0.1:1".into(),
1677 protocol: "opencode-http".into(),
1678 },
1679 },
1680 base_url: "http://127.0.0.1:1".into(),
1681 cwd: "/tmp".into(),
1682 client: reqwest::Client::new(),
1683 receiver,
1684 child: None,
1685 };
1686
1687 let event = connection.next_event().await.unwrap().unwrap();
1688
1689 assert_eq!(event.kind, "message.part.delta");
1690 assert_eq!(event.payload["properties"]["sessionID"], "local-session");
1691 }
1692
1693 #[cfg(unix)]
1694 #[tokio::test]
1695 async fn opencode_shutdown_reaps_a_launcher_process_group() {
1696 let mut command = Command::new("/bin/sh");
1697 command
1698 .args(["-c", "sleep 30 & wait"])
1699 .stdin(Stdio::null())
1700 .stdout(Stdio::null())
1701 .stderr(Stdio::null())
1702 .kill_on_drop(true)
1703 .process_group(0);
1704 let mut child = command.spawn().unwrap();
1705 let pid = child.id().unwrap();
1706
1707 terminate_opencode_server(&mut child).await.unwrap();
1708
1709 assert!(child.try_wait().unwrap().is_some());
1710 let group_still_exists = unsafe { libc::kill(-(pid as libc::pid_t), 0) } == 0;
1711 assert!(
1712 !group_still_exists,
1713 "OpenCode worker process group survived close"
1714 );
1715 }
1716
1717 #[cfg(unix)]
1718 #[tokio::test]
1719 async fn opencode_shutdown_reaps_workers_after_launcher_exit() {
1720 let mut command = Command::new("/bin/sh");
1721 command
1722 .args(["-c", "sleep 30 & exit 0"])
1723 .stdin(Stdio::null())
1724 .stdout(Stdio::null())
1725 .stderr(Stdio::null())
1726 .kill_on_drop(true)
1727 .process_group(0);
1728 let mut child = command.spawn().unwrap();
1729 let pid = child.id().unwrap();
1730 tokio::time::sleep(Duration::from_millis(200)).await;
1731
1732 terminate_opencode_server(&mut child).await.unwrap();
1733
1734 assert!(child.try_wait().unwrap().is_some());
1735 assert!(
1736 !process_group_exists(pid),
1737 "OpenCode worker process group survived its exited launcher"
1738 );
1739 }
1740
1741 #[tokio::test]
1742 async fn opencode_health_probe_is_bounded_when_a_socket_never_responds() {
1743 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1744 let address = listener.local_addr().unwrap();
1745 let server = tokio::spawn(async move {
1746 let (_socket, _) = listener.accept().await.unwrap();
1747 tokio::time::sleep(Duration::from_secs(30)).await;
1748 });
1749 let started = tokio::time::Instant::now();
1750
1751 let error = wait_for_health_for(&format!("http://{address}"), Duration::from_millis(200))
1752 .await
1753 .unwrap_err();
1754
1755 assert!(error.to_string().contains("health request timed out"));
1756 assert!(started.elapsed() < Duration::from_secs(1));
1757 server.abort();
1758 }
1759
1760 #[cfg(unix)]
1761 #[tokio::test]
1762 async fn acp_adapter_negotiates_starts_and_streams_without_blocking_prompt() {
1763 let script = r#"
1764 i=0
1765 while IFS= read -r line; do
1766 i=$((i + 1))
1767 case "$i" in
1768 1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
1769 2) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"acp_mock"}}' ;;
1770 3)
1771 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp_mock","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}'
1772 printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
1773 ;;
1774 esac
1775 done
1776 "#;
1777 let backend = AcpRuntimeBackend::new(
1778 HarnessId::from("mock-acp"),
1779 RuntimeLaunch {
1780 program: "/bin/sh".into(),
1781 arguments: vec!["-c".into(), script.into()],
1782 env: BTreeMap::new(),
1783 },
1784 );
1785 let mut connection = backend
1786 .start(RuntimeStartRequest {
1787 cwd: std::env::current_dir().unwrap(),
1788 launch: None,
1789 })
1790 .await
1791 .unwrap();
1792 assert_eq!(connection.handle().runtime_id, "acp_mock");
1793 assert_eq!(
1794 connection
1795 .send_input(RuntimeInput {
1796 text: "hi".into(),
1797 image_urls: Vec::new(),
1798 })
1799 .await
1800 .unwrap()
1801 .as_deref(),
1802 Some("3")
1803 );
1804 assert_eq!(
1805 connection.next_event().await.unwrap().unwrap().kind,
1806 "session/update"
1807 );
1808 assert_eq!(
1809 connection.next_event().await.unwrap().unwrap().kind,
1810 "supercode/acp_request_completed"
1811 );
1812 connection.close().await.unwrap();
1813 }
1814
1815 #[cfg(unix)]
1816 #[tokio::test]
1817 async fn acp_uses_an_existing_login_before_trying_an_advertised_auth_method() {
1818 let script = r#"
1819 i=0
1820 while IFS= read -r line; do
1821 i=$((i + 1))
1822 if [ "$i" -eq 1 ]; then
1823 printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[{"id":"cached_token"}]}}'
1824 elif printf '%s' "$line" | grep -q 'session/new'; then
1825 printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"existing_login"}}'
1826 else
1827 exit 9
1828 fi
1829 done
1830 "#;
1831 let backend = AcpRuntimeBackend::new(
1832 HarnessId::from("mock-acp"),
1833 RuntimeLaunch {
1834 program: "/bin/sh".into(),
1835 arguments: vec!["-c".into(), script.into()],
1836 env: BTreeMap::new(),
1837 },
1838 );
1839 let mut connection = backend
1840 .start(RuntimeStartRequest {
1841 cwd: std::env::current_dir().unwrap(),
1842 launch: None,
1843 })
1844 .await
1845 .unwrap();
1846 assert_eq!(connection.handle().runtime_id, "existing_login");
1847 connection.close().await.unwrap();
1848 }
1849
1850 #[cfg(unix)]
1851 #[tokio::test]
1852 async fn known_acp_agent_reports_and_uses_load_session_for_resume() {
1853 let script = r#"
1854 i=0
1855 while IFS= read -r line; do
1856 i=$((i + 1))
1857 case "$i" in
1858 1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true},"authMethods":[]}}' ;;
1859 2)
1860 case "$line" in
1861 *'"method":"session/load"'*'"sessionId":"existing-session"'*)
1862 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"historical replay"}}}}'
1863 printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{}}'
1864 ;;
1865 *) exit 42 ;;
1866 esac
1867 ;;
1868 3)
1869 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fresh output"}}}}'
1870 printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
1871 ;;
1872 esac
1873 done
1874 "#;
1875 let backend = AcpRuntimeBackend::new(
1876 HarnessId::from("known-acp"),
1877 RuntimeLaunch {
1878 program: "/bin/sh".into(),
1879 arguments: vec!["-c".into(), script.into()],
1880 env: BTreeMap::new(),
1881 },
1882 )
1883 .with_resume_support(true);
1884 assert!(backend.capabilities().resume_session);
1885 let mut connection = backend
1886 .attach(RuntimeAttachRequest {
1887 runtime_id: "existing-session".into(),
1888 cwd: Some(std::env::current_dir().unwrap()),
1889 launch: None,
1890 })
1891 .await
1892 .unwrap();
1893 assert_eq!(connection.handle().runtime_id, "existing-session");
1894 assert_eq!(
1895 connection
1896 .send_input(RuntimeInput {
1897 text: "continue".into(),
1898 image_urls: Vec::new(),
1899 })
1900 .await
1901 .unwrap()
1902 .as_deref(),
1903 Some("3")
1904 );
1905 let event = connection.next_event().await.unwrap().unwrap();
1906 assert_eq!(event.kind, "session/update");
1907 assert_eq!(
1908 event
1909 .payload
1910 .pointer("/params/update/content/text")
1911 .and_then(Value::as_str),
1912 Some("fresh output")
1913 );
1914 assert_eq!(
1915 connection.next_event().await.unwrap().unwrap().kind,
1916 "supercode/acp_request_completed"
1917 );
1918 connection.close().await.unwrap();
1919 }
1920}