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