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