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