Skip to main content

supercode_harness/runtime/
adapters.rs

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