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, PathBuf};
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/// 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            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/// Claude Code live-runtime backend using bidirectional stream-json print
166/// mode with supercode registered as the CLI's permission handler.
167///
168/// It can create/resume sessions, cancel the running turn through the
169/// stream-json control channel, and answer the permission requests the CLI
170/// raises while a tool call is blocked.
171#[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    /// Use `claude` from `PATH` in bidirectional stream-json mode, with
185    /// supercode registered as the permission handler.
186    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                    // Register this adapter as the permission handler. Measured
198                    // against claude 2.1.258: without it a tool call that needs
199                    // an answer is refused outright with
200                    // `{"type":"system","subtype":"permission_denied"}`; with
201                    // it the CLI raises a `can_use_tool` control request on
202                    // stdout and blocks the turn until a `control_response`
203                    // arrives. See
204                    // `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`.
205                    "--permission-prompt-tool".into(),
206                    "stdio".into(),
207                ],
208                env: BTreeMap::new(),
209            },
210            permission_timeout: CLAUDE_PERMISSION_RESPONSE_TIMEOUT,
211        }
212    }
213
214    /// The command prefix this backend launches: what the registry publishes
215    /// as `default_launch`, so a caller that hands the published launch back
216    /// (the orchestrator does) gets stream-json mode and not the interactive
217    /// TUI blocked on a pipe.
218    pub fn launch(&self) -> &RuntimeLaunch {
219        &self.launch
220    }
221
222    /// Use an explicit Claude Code stream-json command prefix.
223    pub fn with_launch(launch: RuntimeLaunch) -> Self {
224        Self {
225            launch,
226            permission_timeout: CLAUDE_PERMISSION_RESPONSE_TIMEOUT,
227        }
228    }
229
230    /// Override how long an unanswered permission request is held before this
231    /// adapter denies it on the caller's behalf.
232    ///
233    /// See [`CLAUDE_PERMISSION_RESPONSE_TIMEOUT`] for the default and what it
234    /// was measured against.
235    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        // The prefix WITHOUT session arguments is kept: it is what reopens
248        // this same session later, and appending `--session-id` to a launch
249        // that already carries one is what the CLI refuses.
250        let prefix = launch.unwrap_or_else(|| self.launch.clone());
251        let mut launch = prefix.clone();
252        launch.arguments.extend(if resume {
253            vec!["--resume".into(), runtime_id.clone()]
254        } else {
255            vec!["--session-id".into(), runtime_id.clone()]
256        });
257        let transport = RawLineTransport::spawn(&launch, Some(cwd), "claude-stream-json").await?;
258        Ok(Box::new(ClaudeRuntimeConnection {
259            handle: RuntimeHandle {
260                harness: HarnessId::from(HarnessId::CLAUDE_CODE),
261                runtime_id,
262                endpoint: transport.endpoint.clone(),
263            },
264            transport,
265            prefix,
266            cwd: cwd.to_path_buf(),
267            spoke: false,
268            buffered_events: VecDeque::new(),
269            next_control_request: 1,
270            control_timeout: CLAUDE_CONTROL_RESPONSE_TIMEOUT,
271            pending_permissions: Vec::new(),
272            permission_timeout: self.permission_timeout,
273        }))
274    }
275}
276
277/// How long `interrupt` waits for the CLI's matching `control_response` before
278/// returning a structured error instead of hanging the caller.
279///
280/// Measured against claude 2.1.224: an interrupt issued while a turn is in
281/// flight is acknowledged in ~1 ms, but one issued during process startup —
282/// before the CLI has emitted `system/init` — is queued behind session-start
283/// hooks and took 1.15 s to acknowledge on a warm box. The bound is set well
284/// above the slow case so a legitimately busy startup is never reported as a
285/// protocol failure.
286const CLAUDE_CONTROL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
287
288/// How long an unanswered `can_use_tool` request is held before this adapter
289/// denies it on the caller's behalf.
290///
291/// Measured against claude 2.1.258: a `can_use_tool` control request the host
292/// never answers blocks the turn indefinitely — the probe watched one sit for
293/// 20 s with no further frame and no tool result, and the CLI has no bound of
294/// its own (receipt step `unanswered_request_blocks_the_turn` in
295/// `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`). The
296/// bound therefore has to live here. Five minutes is long enough for an
297/// operator to read `supercode approvals list` and answer, and short enough
298/// that a forgotten prompt does not wedge a driven session forever. Override
299/// per backend with [`ClaudeCodeRuntimeBackend::with_permission_timeout`].
300pub const CLAUDE_PERMISSION_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300);
301
302/// The `behavior` values Claude Code's permission handler protocol accepts.
303///
304/// Measured against claude 2.1.258: anything else — including a `deny` with no
305/// `message` — is refused by the CLI with "The canUseTool callback returned an
306/// invalid permission result. Expected {behavior: 'allow', updatedInput?:
307/// object} or {behavior: 'deny', message: string}."
308const CLAUDE_PERMISSION_BEHAVIORS: [&str; 2] = ["allow", "deny"];
309
310/// What this adapter says when it denies a request nobody answered in time.
311const CLAUDE_PERMISSION_TIMEOUT_MESSAGE: &str =
312    "supercode denied this permission request: no answer arrived before the adapter's \
313     permission timeout elapsed";
314
315#[async_trait]
316impl RuntimeBackend for ClaudeCodeRuntimeBackend {
317    fn harness(&self) -> HarnessId {
318        HarnessId::from(HarnessId::CLAUDE_CODE)
319    }
320
321    fn capabilities(&self) -> RuntimeCapabilities {
322        RuntimeCapabilities {
323            start_session: true,
324            resume_session: true,
325            attach_existing_process: false,
326            send_input: true,
327            stream_events: true,
328            interrupt: true,
329            steer: true,
330            respond_to_requests: true,
331        }
332    }
333
334    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
335        self.open(&request.cwd, generated_session_id(), request.launch, false)
336            .await
337    }
338
339    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
340        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
341        self.open(&cwd, request.runtime_id, request.launch, true)
342            .await
343    }
344}
345
346struct ClaudeRuntimeConnection {
347    handle: RuntimeHandle,
348    transport: RawLineTransport,
349    /// The launch prefix this session was opened with, carrying no session
350    /// argument of its own: half of what reopens the session after its
351    /// process has exited.
352    prefix: RuntimeLaunch,
353    /// The workspace the session belongs to. `--resume` resolves a session id
354    /// against the project the process runs in, so a reopen has to run in the
355    /// same directory the first process did.
356    cwd: PathBuf,
357    /// Whether the process now behind this connection has produced a frame.
358    /// A process that exited without ever speaking never opened a session, so
359    /// its close is a launch failure and is reported; one that spoke left a
360    /// session `--resume` reopens.
361    spoke: bool,
362    /// Native events read off the transport while `interrupt` was waiting for
363    /// its `control_response`. They are handed to `next_event` in arrival order
364    /// so cancelling a turn never costs the consumer an event.
365    buffered_events: VecDeque<Value>,
366    next_control_request: u64,
367    control_timeout: Duration,
368    /// Permission requests the CLI raised and is still blocked on, oldest
369    /// first. A request is recorded when this adapter hands it to the event
370    /// consumer and dropped when it is answered or denied on timeout.
371    pending_permissions: Vec<PendingPermission>,
372    permission_timeout: Duration,
373}
374
375/// One `can_use_tool` control request Claude Code is blocked on.
376struct PendingPermission {
377    /// The CLI's own `request_id`, echoed verbatim in the answer.
378    request_id: String,
379    /// When this adapter denies it if nobody has answered.
380    deadline: tokio::time::Instant,
381}
382
383impl ClaudeRuntimeConnection {
384    /// Whether the `claude` process behind this connection has exited.
385    ///
386    /// A stream-json `claude --print` process is the session's CURRENT
387    /// speaker, not the session: it ends when it has nothing left to do (its
388    /// own exit, a crash, a reaped process group), while the session it wrote
389    /// stays on disk under the same id.
390    async fn process_ended(&self) -> bool {
391        matches!(self.transport.child.lock().await.try_wait(), Ok(Some(_)))
392    }
393
394    /// Put a live `claude` process back behind this connection by resuming the
395    /// session it already owns.
396    ///
397    /// The runtime id, the connection and the endpoint's protocol are
398    /// unchanged — only the process is new — so a caller of `harness.v1`
399    /// never learns that one ended. Verified against claude 2.1.258:
400    /// `--resume <id>` in bidirectional stream-json print mode reopens the
401    /// SAME session id (its `system/init` and every `result` echo it) with the
402    /// conversation intact, and goes on reading turns from stdin.
403    async fn reopen(&mut self) -> Result<()> {
404        let mut launch = self.prefix.clone();
405        launch
406            .arguments
407            .extend(["--resume".into(), self.handle.runtime_id.clone()]);
408        let transport = RawLineTransport::spawn(&launch, Some(&self.cwd), "claude-stream-json")
409            .await
410            .map_err(|error| {
411                Error::Other(format!(
412                    "could not resume Claude Code session `{}` after its process exited: {error}",
413                    self.handle.runtime_id
414                ))
415            })?;
416        self.handle.endpoint = transport.endpoint.clone();
417        self.transport = transport;
418        // Whatever the dead process was blocked on died with it: those ids
419        // name nothing the new process can be told about.
420        self.pending_permissions.clear();
421        self.spoke = false;
422        Ok(())
423    }
424
425    /// Write one frame to the CLI, resuming the session first when the process
426    /// that was reading stdin is gone.
427    ///
428    /// Both orders are covered because both happen: the exit can be visible
429    /// before the write (the child is reaped) or only in the write itself (a
430    /// grandchild — a hook, an MCP server — still holds the pipe, so nothing
431    /// upstream has noticed the exit and only `EPIPE` reveals it).
432    async fn write_turn(&mut self, frame: Value) -> Result<()> {
433        if self.process_ended().await {
434            self.reopen().await?;
435        }
436        match self.transport.write(frame.clone()).await {
437            Ok(()) => Ok(()),
438            Err(error) if broken_pipe(&error) => {
439                self.reopen().await?;
440                self.transport.write(frame).await
441            }
442            Err(error) => Err(error),
443        }
444    }
445
446    /// The control channel is adapter-private plumbing: a `control_response` is
447    /// the reply to a frame this adapter sent, never a harness event, so it is
448    /// dropped rather than forwarded to event consumers. A late reply that
449    /// arrives after `interrupt` gave up is dropped here too.
450    fn is_control_response(value: &Value) -> bool {
451        value.get("type").and_then(Value::as_str) == Some("control_response")
452    }
453
454    /// Match one `control_response` envelope against an outstanding request id.
455    ///
456    /// Ground truth (claude 2.1.224, verified live): the CLI answers
457    /// `{"type":"control_request","request_id":ID,"request":{"subtype":"interrupt"}}`
458    /// with
459    /// `{"type":"control_response","response":{"subtype":"success","request_id":ID,"response":{"still_queued":[]}}}`,
460    /// or with `{"subtype":"error","request_id":ID,"error":"…"}` on failure.
461    fn control_result(value: &Value, request_id: &str) -> Option<Result<()>> {
462        let response = value.get("response")?;
463        if response.get("request_id").and_then(Value::as_str) != Some(request_id) {
464            return None;
465        }
466        match response.get("subtype").and_then(Value::as_str) {
467            Some("success") => Some(Ok(())),
468            other => Some(Err(Error::Other(format!(
469                "Claude Code rejected the interrupt control request: {}",
470                response
471                    .get("error")
472                    .and_then(Value::as_str)
473                    .map(str::to_string)
474                    .unwrap_or_else(|| format!(
475                        "control_response subtype {}",
476                        other.unwrap_or("(missing)")
477                    ))
478            )))),
479        }
480    }
481
482    /// The CLI's own `request_id` when this frame is a permission request.
483    ///
484    /// Ground truth (claude 2.1.258, recorded live): a tool call that needs an
485    /// answer from the registered permission handler arrives as
486    /// `{"type":"control_request","request_id":"<uuid>","request":{"subtype":"can_use_tool","tool_name":…,"input":{…},"permission_suggestions":[…],"tool_use_id":…}}`.
487    /// No other inbound `control_request` subtype is answered here.
488    fn permission_request_id(value: &Value) -> Option<&str> {
489        if value.get("type").and_then(Value::as_str)? != "control_request" {
490            return None;
491        }
492        let request = value.get("request")?;
493        if request.get("subtype").and_then(Value::as_str)? != "can_use_tool" {
494            return None;
495        }
496        value.get("request_id").and_then(Value::as_str)
497    }
498
499    /// Start the clock on a permission request about to reach the consumer.
500    fn note_permission_request(&mut self, payload: &Value) {
501        let Some(request_id) = Self::permission_request_id(payload) else {
502            return;
503        };
504        if self
505            .pending_permissions
506            .iter()
507            .any(|pending| pending.request_id == request_id)
508        {
509            return;
510        }
511        self.pending_permissions.push(PendingPermission {
512            request_id: request_id.to_string(),
513            deadline: tokio::time::Instant::now() + self.permission_timeout,
514        });
515    }
516
517    /// Write one `control_response` for a request the CLI is blocked on.
518    async fn write_permission_response(&mut self, request_id: &str, body: Value) -> Result<()> {
519        self.transport
520            .write(json!({
521                "type": "control_response",
522                "response": {
523                    "subtype": "success",
524                    "request_id": request_id,
525                    "response": body,
526                },
527            }))
528            .await
529    }
530
531    /// Deny every held request whose deadline has passed.
532    ///
533    /// Claude Code blocks the turn forever on an unanswered request, so an
534    /// unanswered request is denied rather than left to wedge the session.
535    async fn deny_expired_permissions(&mut self) -> Result<()> {
536        let now = tokio::time::Instant::now();
537        let expired = self
538            .pending_permissions
539            .iter()
540            .filter(|pending| pending.deadline <= now)
541            .map(|pending| pending.request_id.clone())
542            .collect::<Vec<_>>();
543        self.pending_permissions
544            .retain(|pending| pending.deadline > now);
545        for request_id in expired {
546            self.write_permission_response(
547                &request_id,
548                json!({"behavior": "deny", "message": CLAUDE_PERMISSION_TIMEOUT_MESSAGE}),
549            )
550            .await?;
551        }
552        Ok(())
553    }
554
555    /// What `next_event` reports when the CLI's stdout ended.
556    ///
557    /// For every other harness a closed transport is a closed runtime, and
558    /// reporting it is right. Claude Code is the one whose process is not the
559    /// session: `claude --print` exits when it has answered, and the session
560    /// it wrote is reopened by the next `send_input`. Reporting that exit as
561    /// `transport_closed` is what made this harness the only one whose
562    /// connection died between turns — the service drops a closed runtime, so
563    /// the caller's next input is refused on a connection the harness itself
564    /// considers perfectly resumable.
565    ///
566    /// So a session that has spoken PARKS here instead: the connection stays,
567    /// and it has nothing to say until someone speaks to it again — which is
568    /// exactly the state an idle runtime of any other harness is in. A process
569    /// that exited without ever speaking opened no session to park on, and its
570    /// close is reported as before.
571    async fn transport_ended(&mut self) -> Result<Option<HarnessEvent>> {
572        if !self.spoke {
573            return Ok(None);
574        }
575        std::future::pending().await
576    }
577
578    /// How long until the oldest held request must be denied.
579    fn next_permission_deadline(&self) -> Option<Duration> {
580        let now = tokio::time::Instant::now();
581        self.pending_permissions
582            .iter()
583            .map(|pending| pending.deadline.saturating_duration_since(now))
584            .min()
585    }
586}
587
588/// Validate one caller-supplied answer against the permission-result shape
589/// Claude Code accepts, filling in the parts the CLI requires.
590///
591/// The uniform door (`harness.v1.approvals.resolve`) sends
592/// `{"behavior":"allow"}` or `{"behavior":"deny","message":…}`; a caller using
593/// `harness.v1.runtimes.respond` directly may add the CLI's optional fields
594/// (`updatedInput`, `updatedPermissions`) and they are passed through
595/// untouched. Anything without a recognized `behavior` is refused by name
596/// rather than guessed at, because the CLI itself refuses it.
597fn claude_permission_result(response: Value) -> Result<Value> {
598    let Value::Object(mut body) = response else {
599        return Err(claude_permission_shape_error(&response));
600    };
601    match body.get("behavior").and_then(Value::as_str) {
602        Some("allow") => {}
603        Some("deny") => {
604            // Measured: the CLI rejects a `deny` with no `message`.
605            let empty = body
606                .get("message")
607                .and_then(Value::as_str)
608                .is_none_or(str::is_empty);
609            if empty {
610                body.insert(
611                    "message".into(),
612                    Value::String("supercode denied this permission request".into()),
613                );
614            }
615        }
616        _ => return Err(claude_permission_shape_error(&Value::Object(body))),
617    }
618    Ok(Value::Object(body))
619}
620
621fn claude_permission_shape_error(response: &Value) -> Error {
622    Error::Other(format!(
623        "Claude Code permission answers must carry a `behavior` of {}; got {response}",
624        CLAUDE_PERMISSION_BEHAVIORS
625            .map(|behavior| format!("`{behavior}`"))
626            .join(" or "),
627    ))
628}
629
630#[async_trait]
631impl RuntimeConnection for ClaudeRuntimeConnection {
632    fn handle(&self) -> &RuntimeHandle {
633        &self.handle
634    }
635
636    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
637        let content = if input.image_urls.is_empty() {
638            Value::String(input.text)
639        } else {
640            let mut parts = Vec::new();
641            if !input.text.is_empty() {
642                parts.push(json!({"type":"text", "text":input.text}));
643            }
644            for url in input.image_urls {
645                parts.push(claude_image_part(&url)?);
646            }
647            Value::Array(parts)
648        };
649        self.write_turn(json!({
650            "type": "user",
651            "session_id": self.handle.runtime_id,
652            "message": {"role": "user", "content": content},
653        }))
654        .await?;
655        Ok(None)
656    }
657
658    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
659        if let Some(payload) = self.buffered_events.pop_front() {
660            self.spoke = true;
661            return Ok(Some(harness_event(payload)));
662        }
663        loop {
664            // A held permission request has a deadline, so the wait is bounded
665            // by the nearest one: an unanswered request is denied here rather
666            // than left blocking the CLI's turn forever.
667            self.deny_expired_permissions().await?;
668            let payload = match self.next_permission_deadline() {
669                Some(remaining) => {
670                    match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
671                        Err(_) => continue,
672                        Ok(None) => return self.transport_ended().await,
673                        Ok(Some(payload)) => payload,
674                    }
675                }
676                None => match self.transport.receiver.recv().await {
677                    None => return self.transport_ended().await,
678                    Some(payload) => payload,
679                },
680            };
681            self.spoke = true;
682            if Self::is_control_response(&payload) {
683                continue;
684            }
685            self.note_permission_request(&payload);
686            return Ok(Some(harness_event(payload)));
687        }
688    }
689
690    /// Cancel the running turn through the stream-json control channel and wait
691    /// for the CLI's acknowledgement.
692    ///
693    /// Interrupting with no turn in flight is safe and succeeds: claude 2.1.224
694    /// acknowledges the request with `subtype: "success"` and an empty
695    /// `still_queued` list rather than erroring, and the session keeps
696    /// accepting input. The adapter reports what the harness reports instead of
697    /// inventing a turn-state gate of its own.
698    async fn interrupt(&mut self) -> Result<()> {
699        // A session whose process has exited has no turn in flight, and the
700        // adapter does not start one just to cancel nothing. This is the same
701        // answer the CLI gives an interrupt with nothing running.
702        if self.process_ended().await {
703            return Ok(());
704        }
705        let request_id = format!(
706            "supercode-{}-interrupt-{}",
707            self.handle.runtime_id, self.next_control_request
708        );
709        self.next_control_request += 1;
710        self.transport
711            .write(json!({
712                "type": "control_request",
713                "request_id": request_id,
714                "request": {"subtype": "interrupt"},
715            }))
716            .await?;
717
718        let deadline = tokio::time::Instant::now() + self.control_timeout;
719        loop {
720            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
721            if remaining.is_zero() {
722                return Err(claude_interrupt_timeout(self.control_timeout));
723            }
724            match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
725                Err(_) => return Err(claude_interrupt_timeout(self.control_timeout)),
726                Ok(None) => return Err(Error::Other(
727                    "Claude Code stream-json transport closed before acknowledging the interrupt"
728                        .into(),
729                )),
730                Ok(Some(payload)) => {
731                    if Self::is_control_response(&payload) {
732                        if let Some(result) = Self::control_result(&payload, &request_id) {
733                            return result;
734                        }
735                        continue;
736                    }
737                    // A permission request raised while the interrupt was in
738                    // flight is still a request the CLI is blocked on: start
739                    // its clock now, not when the consumer drains the buffer.
740                    self.note_permission_request(&payload);
741                    self.buffered_events.push_back(payload);
742                }
743            }
744        }
745    }
746
747    async fn steer(&mut self, text: String) -> Result<()> {
748        self.send_input(RuntimeInput {
749            text,
750            image_urls: Vec::new(),
751        })
752        .await
753        .map(|_| ())
754    }
755
756    /// Answer one `can_use_tool` permission request through the stream-json
757    /// control channel.
758    ///
759    /// Only a request this adapter has surfaced to the event consumer is
760    /// answerable: the CLI's `request_id` is the identity, the answer is sent
761    /// once, and a second answer for the same id is refused rather than
762    /// silently ignored (claude 2.1.258 logs and drops a duplicate
763    /// `control_response`).
764    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
765        let Some(request_id) = request_id.as_str().map(str::to_string) else {
766            return Err(Error::Other(format!(
767                "Claude Code control requests are identified by a string `request_id`; got \
768                 {request_id}"
769            )));
770        };
771        let Some(index) = self
772            .pending_permissions
773            .iter()
774            .position(|pending| pending.request_id == request_id)
775        else {
776            return Err(Error::Other(format!(
777                "no Claude Code permission request `{request_id}` is waiting on this connection — \
778                 a `can_use_tool` request is answerable only while its turn is blocked on it, and \
779                 only until it is answered or denied on timeout"
780            )));
781        };
782        let body = claude_permission_result(response)?;
783        self.pending_permissions.remove(index);
784        self.write_permission_response(&request_id, body).await
785    }
786
787    async fn close(&mut self) -> Result<()> {
788        self.transport.close().await
789    }
790}
791
792/// Generic ACP v1 client backend for any ACP agent command.
793#[derive(Debug, Clone)]
794pub struct AcpRuntimeBackend {
795    harness: HarnessId,
796    launch: RuntimeLaunch,
797    resume_session: bool,
798}
799
800impl AcpRuntimeBackend {
801    /// Construct an ACP adapter for a named harness/agent command.
802    pub fn new(harness: HarnessId, launch: RuntimeLaunch) -> Self {
803        Self {
804            harness,
805            launch,
806            resume_session: false,
807        }
808    }
809
810    /// Declare that this known ACP agent advertises `session/load` or
811    /// `session/resume`. Attach still validates the capability negotiated by
812    /// `initialize`, so a changed or incompatible agent fails honestly.
813    pub fn with_resume_support(mut self, supported: bool) -> Self {
814        self.resume_session = supported;
815        self
816    }
817
818    async fn connect(
819        &self,
820        cwd: &Path,
821        launch: Option<RuntimeLaunch>,
822    ) -> Result<(
823        Arc<JsonLineClient>,
824        mpsc::UnboundedReceiver<Value>,
825        RuntimeEndpoint,
826        Value,
827    )> {
828        let launch = launch.unwrap_or_else(|| self.launch.clone());
829        let (client, receiver, endpoint) =
830            JsonLineClient::spawn(&launch, Some(cwd), true, "acp-v1-jsonrpc").await?;
831        let initialized = client
832            .request(
833                "initialize",
834                json!({
835                    "protocolVersion": 1,
836                    "clientCapabilities": {},
837                    "clientInfo": {
838                        "name": "supercode",
839                        "title": "Supercode",
840                        "version": env!("CARGO_PKG_VERSION"),
841                    },
842                }),
843            )
844            .await?;
845        if initialized.get("protocolVersion").and_then(Value::as_u64) != Some(1) {
846            return Err(Error::Other(format!(
847                "ACP agent negotiated unsupported protocol version: {}",
848                initialized
849                    .get("protocolVersion")
850                    .cloned()
851                    .unwrap_or(Value::Null)
852            )));
853        }
854        Ok((client, receiver, endpoint, initialized))
855    }
856
857    async fn session_request(
858        &self,
859        client: &JsonLineClient,
860        initialized: &Value,
861        method: &str,
862        params: Value,
863    ) -> Result<Value> {
864        match client.request(method, params.clone()).await {
865            Ok(response) => Ok(response),
866            Err(error) if acp_auth_required(&error.to_string()) => {
867                let cached = initialized
868                    .get("authMethods")
869                    .and_then(Value::as_array)
870                    .and_then(|methods| {
871                        methods.iter().find_map(|candidate| {
872                            (candidate.get("id").and_then(Value::as_str) == Some("cached_token"))
873                                .then_some("cached_token")
874                        })
875                    });
876                let Some(method_id) = cached else {
877                    return Err(Error::Other(
878                        "ACP agent requires authentication but did not advertise the non-interactive `cached_token` method"
879                            .into(),
880                    ));
881                };
882                client
883                    .request(
884                        "authenticate",
885                        json!({"methodId": method_id, "_meta": {"headless": true}}),
886                    )
887                    .await?;
888                client.request(method, params).await
889            }
890            Err(error) => Err(error),
891        }
892    }
893
894    async fn connection(
895        &self,
896        cwd: &Path,
897        runtime_id: Option<String>,
898        launch: Option<RuntimeLaunch>,
899        mcp_servers: Vec<McpServerLaunch>,
900    ) -> Result<Box<dyn RuntimeConnection>> {
901        let mcp_servers = acp_mcp_servers(&mcp_servers);
902        let (client, mut receiver, endpoint, initialized) = self.connect(cwd, launch).await?;
903        let session_id = if let Some(session_id) = runtime_id {
904            let resume = initialized
905                .pointer("/agentCapabilities/sessionCapabilities/resume")
906                .is_some();
907            let load = initialized
908                .pointer("/agentCapabilities/loadSession")
909                .and_then(Value::as_bool)
910                .unwrap_or(false);
911            let method = if resume {
912                "session/resume"
913            } else if load {
914                "session/load"
915            } else {
916                return Err(Error::Other(
917                    "ACP agent did not advertise session resume or load".into(),
918                ));
919            };
920            self.session_request(
921                client.as_ref(),
922                &initialized,
923                method,
924                json!({"sessionId": session_id, "cwd": cwd, "mcpServers": mcp_servers}),
925            )
926            .await?;
927            session_id
928        } else {
929            self.session_request(
930                client.as_ref(),
931                &initialized,
932                "session/new",
933                json!({"cwd": cwd, "mcpServers": mcp_servers}),
934            )
935            .await?
936            .get("sessionId")
937            .and_then(Value::as_str)
938            .ok_or_else(|| Error::Other("ACP session/new omitted sessionId".into()))?
939            .to_string()
940        };
941        // `session/load` is allowed to replay the persisted conversation as
942        // `session/update` notifications before returning its response. Those
943        // are bootstrap data, not output from a newly submitted prompt. If
944        // they escape through the live runtime stream, clients fabricate an
945        // assistant delta and a turn that can never complete because no
946        // `session/prompt` request exists. The persisted transcript already
947        // supplies this history, so discard every notification queued by the
948        // completed new/load handshake before exposing the connection.
949        while receiver.try_recv().is_ok() {}
950        Ok(Box::new(AcpRuntimeConnection {
951            handle: RuntimeHandle {
952                harness: self.harness.clone(),
953                runtime_id: session_id,
954                endpoint,
955            },
956            client,
957            receiver,
958            active_prompt: None,
959        }))
960    }
961}
962
963/// The uniform [`McpServerLaunch`] list in ACP's own `session/new` shape:
964/// `{name, command, args, env: [{name, value}]}` (Agent Client Protocol v1
965/// `McpServer`, the stdio form). An empty list stays `[]`, which is what this
966/// backend has always sent.
967fn acp_mcp_servers(servers: &[McpServerLaunch]) -> Value {
968    Value::Array(
969        servers
970            .iter()
971            .map(|server| {
972                json!({
973                    "name": server.name,
974                    "command": server.command,
975                    "args": server.arguments,
976                    "env": server
977                        .env
978                        .iter()
979                        .map(|(name, value)| json!({"name": name, "value": value}))
980                        .collect::<Vec<_>>(),
981                })
982            })
983            .collect::<Vec<_>>(),
984    )
985}
986
987fn acp_auth_required(message: &str) -> bool {
988    let message = message.to_ascii_lowercase();
989    [
990        "auth",
991        "login",
992        "sign in",
993        "sign-in",
994        "unauthorized",
995        "forbidden",
996        "credential",
997    ]
998    .iter()
999    .any(|needle| message.contains(needle))
1000}
1001
1002#[async_trait]
1003impl RuntimeBackend for AcpRuntimeBackend {
1004    fn harness(&self) -> HarnessId {
1005        self.harness.clone()
1006    }
1007
1008    fn capabilities(&self) -> RuntimeCapabilities {
1009        RuntimeCapabilities {
1010            start_session: true,
1011            // Optional in ACP v1. Known agents may declare it here; attach
1012            // still checks the actual initialize response before use.
1013            resume_session: self.resume_session,
1014            attach_existing_process: false,
1015            send_input: true,
1016            stream_events: true,
1017            interrupt: true,
1018            steer: false,
1019            respond_to_requests: true,
1020        }
1021    }
1022
1023    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
1024        self.connection(&request.cwd, None, request.launch, request.mcp_servers)
1025            .await
1026    }
1027
1028    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
1029        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1030        self.connection(&cwd, Some(request.runtime_id), request.launch, Vec::new())
1031            .await
1032    }
1033}
1034
1035struct AcpRuntimeConnection {
1036    handle: RuntimeHandle,
1037    client: Arc<JsonLineClient>,
1038    receiver: mpsc::UnboundedReceiver<Value>,
1039    active_prompt: Option<u64>,
1040}
1041
1042#[async_trait]
1043impl RuntimeConnection for AcpRuntimeConnection {
1044    fn handle(&self) -> &RuntimeHandle {
1045        &self.handle
1046    }
1047
1048    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
1049        let mut prompt = Vec::new();
1050        if !input.text.is_empty() {
1051            prompt.push(json!({"type": "text", "text": input.text}));
1052        }
1053        for url in input.image_urls {
1054            let (mime_type, data) = data_image_parts(&url).ok_or_else(|| {
1055                Error::Other("ACP image prompts require base64 image data URLs".into())
1056            })?;
1057            prompt.push(json!({"type":"image", "mimeType":mime_type, "data":data}));
1058        }
1059        let (id, response) = self
1060            .client
1061            .begin_request(
1062                "session/prompt",
1063                json!({
1064                    "sessionId": self.handle.runtime_id,
1065                    "prompt": prompt,
1066                }),
1067            )
1068            .await?;
1069        self.active_prompt = Some(id);
1070        let client = self.client.clone();
1071        tokio::spawn(async move {
1072            let result = match response.await {
1073                Ok(Ok(result)) => json!({"id": id, "result": result}),
1074                Ok(Err(error)) => json!({"id": id, "error": error}),
1075                Err(_) => json!({"id": id, "error": "response channel closed"}),
1076            };
1077            client.emit(json!({
1078                "jsonrpc": "2.0",
1079                "method": "supercode/acp_request_completed",
1080                "params": result,
1081            }));
1082        });
1083        Ok(Some(id.to_string()))
1084    }
1085
1086    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1087        let Some(payload) = self.receiver.recv().await else {
1088            return Ok(None);
1089        };
1090        let kind = payload
1091            .get("method")
1092            .and_then(Value::as_str)
1093            .or_else(|| payload.get("type").and_then(Value::as_str))
1094            .unwrap_or("protocol")
1095            .to_string();
1096        if kind == "supercode/acp_request_completed" {
1097            self.active_prompt = None;
1098        }
1099        Ok(Some(HarnessEvent {
1100            sequence: None,
1101            kind,
1102            payload,
1103        }))
1104    }
1105
1106    async fn interrupt(&mut self) -> Result<()> {
1107        self.client
1108            .notify(
1109                "session/cancel",
1110                json!({"sessionId": self.handle.runtime_id}),
1111            )
1112            .await
1113    }
1114
1115    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1116        self.client.respond(request_id, response).await
1117    }
1118
1119    async fn close(&mut self) -> Result<()> {
1120        self.client.close().await
1121    }
1122}
1123
1124/// OpenCode live-runtime backend using its official HTTP API and SSE event
1125/// stream. [`OpenCodeRuntimeBackend::connect`] can join the server embedded in
1126/// an already-running TUI when that TUI was launched with a known host/port.
1127#[derive(Debug, Clone)]
1128pub struct OpenCodeRuntimeBackend {
1129    launch: RuntimeLaunch,
1130    base_url: Option<String>,
1131    bearer: Option<BearerToken>,
1132}
1133
1134impl Default for OpenCodeRuntimeBackend {
1135    fn default() -> Self {
1136        Self::new()
1137    }
1138}
1139
1140impl OpenCodeRuntimeBackend {
1141    /// Launch a fresh `opencode serve` process for each connection.
1142    pub fn new() -> Self {
1143        Self {
1144            launch: RuntimeLaunch {
1145                program: "opencode".into(),
1146                arguments: vec!["serve".into()],
1147                env: BTreeMap::new(),
1148            },
1149            base_url: None,
1150            bearer: None,
1151        }
1152    }
1153
1154    /// Connect to an existing OpenCode server, including a TUI's server when
1155    /// it was launched on a known address.
1156    pub fn connect(base_url: impl Into<String>) -> Self {
1157        Self {
1158            base_url: Some(base_url.into().trim_end_matches('/').to_string()),
1159            ..Self::new()
1160        }
1161    }
1162
1163    /// Override the command used when launching a new OpenCode server.
1164    pub fn with_launch(mut self, launch: RuntimeLaunch) -> Self {
1165        self.launch = launch;
1166        self
1167    }
1168
1169    /// Send the resolved connect-mode credential as a bearer Authorization
1170    /// header on every request to the joined server.
1171    pub fn with_bearer(mut self, token: BearerToken) -> Self {
1172        self.bearer = Some(token);
1173        self
1174    }
1175
1176    fn http_client(&self) -> Result<reqwest::Client> {
1177        let Some(token) = &self.bearer else {
1178            return Ok(reqwest::Client::new());
1179        };
1180        let mut headers = reqwest::header::HeaderMap::new();
1181        let mut value =
1182            reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token.secret())).map_err(
1183                |_| Error::Other("connect-mode bearer token is not a valid header value".into()),
1184            )?;
1185        value.set_sensitive(true);
1186        headers.insert(reqwest::header::AUTHORIZATION, value);
1187        reqwest::Client::builder()
1188            .default_headers(headers)
1189            .build()
1190            .map_err(|error| Error::Other(format!("could not build HTTP client: {error}")))
1191    }
1192
1193    async fn service(
1194        &self,
1195        client: &reqwest::Client,
1196        launch: Option<RuntimeLaunch>,
1197    ) -> Result<(String, Option<super::GroupLeader>)> {
1198        if let Some(base_url) = &self.base_url {
1199            wait_for_health(client, base_url).await?;
1200            return Ok((base_url.clone(), None));
1201        }
1202        let port = TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port();
1203        let mut launch = launch.unwrap_or_else(|| self.launch.clone());
1204        launch.arguments.extend([
1205            "--hostname".into(),
1206            "127.0.0.1".into(),
1207            "--port".into(),
1208            port.to_string(),
1209        ]);
1210        let mut command = Command::new(&launch.program);
1211        command
1212            .args(&launch.arguments)
1213            .envs(&launch.env)
1214            .stdin(Stdio::null())
1215            .stdout(Stdio::null())
1216            .stderr(Stdio::inherit())
1217            .kill_on_drop(true);
1218        // OpenCode's launcher may replace itself with or spawn a native
1219        // worker. Give the runtime its own group so close can reap the whole
1220        // server tree instead of orphaning the worker and its inherited FDs.
1221        #[cfg(unix)]
1222        command.process_group(0);
1223        // `kill_on_drop` only targets the launcher, and a startup that blows
1224        // its caller's deadline is DROPPED mid-wait. `GroupLeader` turns that
1225        // drop into a group signal; the explicit teardown below covers a
1226        // startup that fails while this future is still running.
1227        let mut child = super::GroupLeader(command.spawn().map_err(|error| {
1228            Error::Other(format!("could not launch {}: {error}", launch.program))
1229        })?);
1230        let base_url = format!("http://127.0.0.1:{port}");
1231        if let Err(error) = wait_for_health(client, &base_url).await {
1232            let _ = terminate_opencode_server(&mut child).await;
1233            return Err(error);
1234        }
1235        Ok((base_url, Some(child)))
1236    }
1237
1238    async fn open(
1239        &self,
1240        cwd: &Path,
1241        runtime_id: Option<String>,
1242        launch: Option<RuntimeLaunch>,
1243    ) -> Result<Box<dyn RuntimeConnection>> {
1244        let client = self.http_client()?;
1245        let (base_url, child) = self.service(&client, launch).await?;
1246        let cwd_string = cwd.to_string_lossy().to_string();
1247        let runtime_id = match runtime_id {
1248            Some(id) => {
1249                http_ok(
1250                    client
1251                        .get(format!("{base_url}/session/{id}"))
1252                        .query(&[("directory", &cwd_string)])
1253                        .send()
1254                        .await,
1255                )
1256                .await?;
1257                id
1258            }
1259            None => {
1260                let response = http_ok(
1261                    client
1262                        .post(format!("{base_url}/session"))
1263                        .query(&[("directory", &cwd_string)])
1264                        .json(&json!({}))
1265                        .send()
1266                        .await,
1267                )
1268                .await?;
1269                response
1270                    .json::<Value>()
1271                    .await
1272                    .map_err(http_error)?
1273                    .get("id")
1274                    .and_then(Value::as_str)
1275                    .ok_or_else(|| Error::Other("OpenCode create session omitted id".into()))?
1276                    .to_string()
1277            }
1278        };
1279        let receiver = spawn_sse(
1280            client.clone(),
1281            format!("{base_url}/event"),
1282            cwd_string.clone(),
1283        );
1284        Ok(Box::new(OpenCodeRuntimeConnection {
1285            handle: RuntimeHandle {
1286                harness: HarnessId::from(HarnessId::OPENCODE),
1287                runtime_id,
1288                endpoint: RuntimeEndpoint::Http {
1289                    base_url: base_url.clone(),
1290                    protocol: "opencode-http-sse".into(),
1291                },
1292            },
1293            base_url,
1294            cwd: cwd_string,
1295            client,
1296            receiver,
1297            child,
1298        }))
1299    }
1300}
1301
1302#[async_trait]
1303impl RuntimeBackend for OpenCodeRuntimeBackend {
1304    fn harness(&self) -> HarnessId {
1305        HarnessId::from(HarnessId::OPENCODE)
1306    }
1307
1308    fn capabilities(&self) -> RuntimeCapabilities {
1309        RuntimeCapabilities {
1310            start_session: true,
1311            resume_session: true,
1312            attach_existing_process: self.base_url.is_some(),
1313            send_input: true,
1314            stream_events: true,
1315            interrupt: true,
1316            steer: false,
1317            respond_to_requests: true,
1318        }
1319    }
1320
1321    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
1322        self.open(&request.cwd, None, request.launch).await
1323    }
1324
1325    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
1326        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1327        self.open(&cwd, Some(request.runtime_id), request.launch)
1328            .await
1329    }
1330
1331    async fn attach_existing(
1332        &self,
1333        request: RuntimeAttachRequest,
1334    ) -> Result<Box<dyn RuntimeConnection>> {
1335        if self.base_url.is_none() {
1336            return Err(Error::Other(
1337                "OpenCode live attach requires the existing server's `base_url`".into(),
1338            ));
1339        }
1340        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1341        self.open(&cwd, Some(request.runtime_id), request.launch)
1342            .await
1343    }
1344}
1345
1346struct OpenCodeRuntimeConnection {
1347    handle: RuntimeHandle,
1348    base_url: String,
1349    cwd: String,
1350    client: reqwest::Client,
1351    receiver: mpsc::UnboundedReceiver<Value>,
1352    child: Option<super::GroupLeader>,
1353}
1354
1355#[async_trait]
1356impl RuntimeConnection for OpenCodeRuntimeConnection {
1357    fn handle(&self) -> &RuntimeHandle {
1358        &self.handle
1359    }
1360
1361    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
1362        let mut parts = Vec::new();
1363        if !input.text.is_empty() {
1364            parts.push(json!({"type": "text", "text": input.text}));
1365        }
1366        for url in input.image_urls {
1367            let mime = image_mime_type(&url).ok_or_else(|| {
1368                Error::Other("OpenCode image prompts require a recognizable image MIME type".into())
1369            })?;
1370            parts.push(json!({"type":"file", "mime":mime, "url":url}));
1371        }
1372        http_ok(
1373            self.client
1374                .post(format!(
1375                    "{}/session/{}/prompt_async",
1376                    self.base_url, self.handle.runtime_id
1377                ))
1378                .query(&[("directory", &self.cwd)])
1379                .json(&json!({"parts": parts}))
1380                .send()
1381                .await,
1382        )
1383        .await?;
1384        Ok(None)
1385    }
1386
1387    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1388        loop {
1389            let Some(payload) = self.receiver.recv().await else {
1390                return Ok(None);
1391            };
1392            if opencode_event_session_id(&payload)
1393                .is_some_and(|session_id| session_id != self.handle.runtime_id)
1394            {
1395                continue;
1396            }
1397            let kind = payload
1398                .get("type")
1399                .and_then(Value::as_str)
1400                .unwrap_or("event")
1401                .to_string();
1402            return Ok(Some(HarnessEvent {
1403                sequence: None,
1404                kind,
1405                payload,
1406            }));
1407        }
1408    }
1409
1410    async fn interrupt(&mut self) -> Result<()> {
1411        http_ok(
1412            self.client
1413                .post(format!(
1414                    "{}/session/{}/abort",
1415                    self.base_url, self.handle.runtime_id
1416                ))
1417                .query(&[("directory", &self.cwd)])
1418                .send()
1419                .await,
1420        )
1421        .await?;
1422        Ok(())
1423    }
1424
1425    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1426        let permission = request_id.as_str().ok_or_else(|| {
1427            Error::Other("OpenCode permission request id must be a string".into())
1428        })?;
1429        http_ok(
1430            self.client
1431                .post(format!(
1432                    "{}/session/{}/permissions/{permission}",
1433                    self.base_url, self.handle.runtime_id
1434                ))
1435                .query(&[("directory", &self.cwd)])
1436                .json(&response)
1437                .send()
1438                .await,
1439        )
1440        .await?;
1441        Ok(())
1442    }
1443
1444    async fn close(&mut self) -> Result<()> {
1445        if let Some(child) = &mut self.child {
1446            terminate_opencode_server(child).await?;
1447        }
1448        Ok(())
1449    }
1450}
1451
1452fn data_image_parts(url: &str) -> Option<(&str, &str)> {
1453    let rest = url.strip_prefix("data:")?;
1454    let (mime_type, data) = rest.split_once(";base64,")?;
1455    mime_type.starts_with("image/").then_some((mime_type, data))
1456}
1457
1458fn image_mime_type(url: &str) -> Option<&str> {
1459    if let Some((mime_type, _)) = data_image_parts(url) {
1460        return Some(mime_type);
1461    }
1462    let path = url.split(['?', '#']).next()?.to_ascii_lowercase();
1463    if path.ends_with(".png") {
1464        Some("image/png")
1465    } else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
1466        Some("image/jpeg")
1467    } else if path.ends_with(".gif") {
1468        Some("image/gif")
1469    } else if path.ends_with(".webp") {
1470        Some("image/webp")
1471    } else {
1472        None
1473    }
1474}
1475
1476fn claude_image_part(url: &str) -> Result<Value> {
1477    if let Some((media_type, data)) = data_image_parts(url) {
1478        return Ok(json!({
1479            "type":"image",
1480            "source":{"type":"base64", "media_type":media_type, "data":data}
1481        }));
1482    }
1483    if url.starts_with("https://") || url.starts_with("http://") {
1484        return Ok(json!({"type":"image", "source":{"type":"url", "url":url}}));
1485    }
1486    Err(Error::Other(
1487        "Claude image prompts require image data URLs or HTTP(S) URLs".into(),
1488    ))
1489}
1490
1491fn opencode_event_session_id(payload: &Value) -> Option<&str> {
1492    let properties = payload.get("properties").unwrap_or(payload);
1493    properties
1494        .get("sessionID")
1495        .and_then(Value::as_str)
1496        .or_else(|| {
1497            properties
1498                .get("part")
1499                .and_then(|part| part.get("sessionID"))
1500                .and_then(Value::as_str)
1501        })
1502        .or_else(|| {
1503            properties
1504                .get("info")
1505                .and_then(|info| info.get("sessionID"))
1506                .and_then(Value::as_str)
1507        })
1508}
1509
1510async fn terminate_opencode_server(child: &mut Child) -> Result<()> {
1511    #[cfg(unix)]
1512    let process_group = child.id();
1513    let leader_exited = child.try_wait()?.is_some();
1514    if leader_exited {
1515        #[cfg(unix)]
1516        if let Some(pid) = process_group.filter(|pid| process_group_exists(*pid)) {
1517            crate::lsp::kill_process_group(pid);
1518            wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1519        }
1520        return Ok(());
1521    }
1522    // `Child::kill().await` waits for process reaping and can block forever
1523    // when a launcher leaves its native worker and inherited handles alive.
1524    // Terminate the isolated group while its leader can still reap workers;
1525    // killing leader and workers simultaneously can leave transient orphan
1526    // zombies and made close observably race process cleanup on Linux.
1527    #[cfg(unix)]
1528    if let Some(pid) = process_group {
1529        unsafe {
1530            libc::kill(-(pid as libc::pid_t), libc::SIGTERM);
1531        }
1532        let mut leader_reaped = false;
1533        if let Ok(status) = tokio::time::timeout(Duration::from_millis(500), child.wait()).await {
1534            status?;
1535            leader_reaped = true;
1536            if !process_group_exists(pid) {
1537                return Ok(());
1538            }
1539        }
1540        // The leader may exit while a detached worker ignores SIGTERM. Do
1541        // not mistake a reaped launcher for a stopped server tree.
1542        crate::lsp::kill_process_group(pid);
1543        if leader_reaped {
1544            return wait_for_process_group_exit(pid, Duration::from_secs(3)).await;
1545        }
1546    }
1547    #[cfg(not(unix))]
1548    child.start_kill()?;
1549    tokio::time::timeout(Duration::from_secs(3), child.wait())
1550        .await
1551        .map_err(|_| Error::Other("timed out reaping the OpenCode server".into()))??;
1552    #[cfg(unix)]
1553    if let Some(pid) = process_group {
1554        wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1555    }
1556    Ok(())
1557}
1558
1559#[cfg(unix)]
1560fn process_group_exists(pid: u32) -> bool {
1561    let result = unsafe { libc::kill(-(pid as libc::pid_t), 0) };
1562    result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1563}
1564
1565#[cfg(unix)]
1566async fn wait_for_process_group_exit(pid: u32, timeout: Duration) -> Result<()> {
1567    let deadline = tokio::time::Instant::now() + timeout;
1568    while process_group_exists(pid) {
1569        if tokio::time::Instant::now() >= deadline {
1570            return Err(Error::Other(format!(
1571                "timed out stopping OpenCode process group {pid}"
1572            )));
1573        }
1574        tokio::time::sleep(Duration::from_millis(10)).await;
1575    }
1576    Ok(())
1577}
1578
1579struct RawLineTransport {
1580    stdin: Mutex<ChildStdin>,
1581    child: Mutex<super::GroupLeader>,
1582    receiver: mpsc::UnboundedReceiver<Value>,
1583    endpoint: RuntimeEndpoint,
1584}
1585
1586impl RawLineTransport {
1587    async fn spawn(launch: &RuntimeLaunch, cwd: Option<&Path>, protocol: &str) -> Result<Self> {
1588        let mut command = Command::new(&launch.program);
1589        command
1590            .args(&launch.arguments)
1591            .envs(&launch.env)
1592            .stdin(Stdio::piped())
1593            .stdout(Stdio::piped())
1594            .stderr(Stdio::inherit())
1595            .kill_on_drop(true);
1596        // A raw-protocol launcher spawns its worker the same way a JSON-line
1597        // one does. Give it its own group so close — and a dropped deadline —
1598        // reap the worker instead of orphaning it holding this stdout.
1599        #[cfg(unix)]
1600        command.process_group(0);
1601        if let Some(cwd) = cwd {
1602            command.current_dir(cwd);
1603        }
1604        let mut child = command.spawn().map_err(|error| {
1605            Error::Other(format!("could not launch {}: {error}", launch.program))
1606        })?;
1607        let pid = child.id();
1608        let stdin = child
1609            .stdin
1610            .take()
1611            .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
1612        let stdout = child
1613            .stdout
1614            .take()
1615            .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
1616        let (sender, receiver) = mpsc::unbounded_channel();
1617        tokio::spawn(async move {
1618            let mut lines = BufReader::new(stdout).lines();
1619            while let Ok(Some(line)) = lines.next_line().await {
1620                let value = serde_json::from_str(&line)
1621                    .unwrap_or_else(|_| json!({"type": "malformed_output", "line": line}));
1622                let _ = sender.send(value);
1623            }
1624        });
1625        Ok(Self {
1626            stdin: Mutex::new(stdin),
1627            child: Mutex::new(super::GroupLeader(child)),
1628            receiver,
1629            endpoint: RuntimeEndpoint::LocalProcess {
1630                pid,
1631                command: std::iter::once(launch.program.clone())
1632                    .chain(launch.arguments.iter().cloned())
1633                    .collect(),
1634                protocol: protocol.into(),
1635            },
1636        })
1637    }
1638
1639    async fn write(&self, value: Value) -> Result<()> {
1640        let mut stdin = self.stdin.lock().await;
1641        stdin.write_all(value.to_string().as_bytes()).await?;
1642        stdin.write_all(b"\n").await?;
1643        stdin.flush().await?;
1644        Ok(())
1645    }
1646
1647    async fn close(&self) -> Result<()> {
1648        let mut child = self.child.lock().await;
1649        if child.try_wait()?.is_some() {
1650            return Ok(());
1651        }
1652        // `Child::kill` reaches the launcher only. Signal the whole group so
1653        // a shim's worker cannot outlive the connection that owns it.
1654        #[cfg(unix)]
1655        if let Some(pid) = child.id() {
1656            crate::lsp::kill_process_group(pid);
1657            tokio::time::timeout(Duration::from_secs(3), child.wait())
1658                .await
1659                .map_err(|_| Error::Other("timed out reaping runtime process group".into()))??;
1660            return Ok(());
1661        }
1662        #[cfg(not(unix))]
1663        child.kill().await?;
1664        Ok(())
1665    }
1666}
1667
1668async fn raw_next_event(
1669    receiver: &mut mpsc::UnboundedReceiver<Value>,
1670) -> Result<Option<HarnessEvent>> {
1671    let Some(payload) = receiver.recv().await else {
1672        return Ok(None);
1673    };
1674    Ok(Some(harness_event(payload)))
1675}
1676
1677fn harness_event(payload: Value) -> HarnessEvent {
1678    let kind = payload
1679        .get("type")
1680        .and_then(Value::as_str)
1681        .unwrap_or("event")
1682        .to_string();
1683    HarnessEvent {
1684        sequence: None,
1685        kind,
1686        payload,
1687    }
1688}
1689
1690/// Whether this error is the write end of a pipe whose reader is gone.
1691///
1692/// `EPIPE` is how a process exit reaches a writer that had no other way to
1693/// see it: the child's stdout can still be held open by a grandchild it
1694/// spawned, so nothing upstream has noticed, and the failed write is the
1695/// first evidence.
1696fn broken_pipe(error: &Error) -> bool {
1697    matches!(error, Error::Io(io) if io.kind() == std::io::ErrorKind::BrokenPipe)
1698}
1699
1700fn claude_interrupt_timeout(bound: Duration) -> Error {
1701    Error::Other(format!(
1702        "Claude Code did not acknowledge the interrupt control request within {}s",
1703        bound.as_secs_f32()
1704    ))
1705}
1706
1707pub(crate) fn generated_session_id() -> String {
1708    let mut bytes = [0_u8; 16];
1709    if getrandom::getrandom(&mut bytes).is_err() {
1710        let nanos = SystemTime::now()
1711            .duration_since(UNIX_EPOCH)
1712            .unwrap_or_default()
1713            .as_nanos()
1714            .to_le_bytes();
1715        bytes.copy_from_slice(&nanos);
1716    }
1717    bytes[6] = (bytes[6] & 0x0f) | 0x40;
1718    bytes[8] = (bytes[8] & 0x3f) | 0x80;
1719    format!(
1720        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
1721        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
1722        bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
1723    )
1724}
1725
1726async fn wait_for_health(client: &reqwest::Client, base_url: &str) -> Result<()> {
1727    wait_for_health_for(client, base_url, Duration::from_secs(10)).await
1728}
1729
1730async fn wait_for_health_for(
1731    client: &reqwest::Client,
1732    base_url: &str,
1733    total_timeout: Duration,
1734) -> Result<()> {
1735    let url = format!("{base_url}/global/health");
1736    let mut last = None;
1737    let deadline = tokio::time::Instant::now() + total_timeout;
1738    // Local package-manager shims can take longer than five seconds to start
1739    // under build or indexing load. Ten seconds avoids false unavailability
1740    // without permitting an unbounded launch; inventory handshakes retain
1741    // their separate 30-second bound around the complete startup.
1742    loop {
1743        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1744        if remaining.is_zero() {
1745            break;
1746        }
1747        let request_timeout = remaining.min(Duration::from_millis(500));
1748        match tokio::time::timeout(request_timeout, client.get(&url).send()).await {
1749            Ok(Ok(response)) if response.status().is_success() => return Ok(()),
1750            Ok(Ok(response)) => last = Some(format!("HTTP {}", response.status())),
1751            Ok(Err(error)) => last = Some(error.to_string()),
1752            Err(_) => last = Some("health request timed out".into()),
1753        }
1754        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1755        if !remaining.is_zero() {
1756            tokio::time::sleep(remaining.min(Duration::from_millis(100))).await;
1757        }
1758    }
1759    Err(Error::Other(format!(
1760        "OpenCode server at {base_url} did not become healthy: {}",
1761        last.unwrap_or_else(|| "no response".into())
1762    )))
1763}
1764
1765async fn http_ok(
1766    response: std::result::Result<reqwest::Response, reqwest::Error>,
1767) -> Result<reqwest::Response> {
1768    response
1769        .map_err(http_error)?
1770        .error_for_status()
1771        .map_err(http_error)
1772}
1773
1774fn http_error(error: reqwest::Error) -> Error {
1775    Error::Other(format!("runtime HTTP request failed: {error}"))
1776}
1777
1778fn spawn_sse(
1779    client: reqwest::Client,
1780    url: String,
1781    directory: String,
1782) -> mpsc::UnboundedReceiver<Value> {
1783    let (sender, receiver) = mpsc::unbounded_channel();
1784    tokio::spawn(async move {
1785        let response = client
1786            .get(url)
1787            .query(&[("directory", directory)])
1788            .send()
1789            .await;
1790        let Ok(response) = response.and_then(reqwest::Response::error_for_status) else {
1791            let _ = sender.send(
1792                json!({"type": "stream_error", "message": "could not open OpenCode SSE stream"}),
1793            );
1794            return;
1795        };
1796        let mut stream = response.bytes_stream();
1797        let mut buffer = String::new();
1798        while let Some(chunk) = stream.next().await {
1799            let Ok(chunk) = chunk else {
1800                break;
1801            };
1802            buffer.push_str(&String::from_utf8_lossy(&chunk));
1803            while let Some(newline) = buffer.find('\n') {
1804                let line = buffer[..newline].trim_end_matches('\r').to_string();
1805                buffer.drain(..=newline);
1806                if let Some(data) = line.strip_prefix("data:") {
1807                    let data = data.trim();
1808                    if let Ok(value) = serde_json::from_str(data) {
1809                        let _ = sender.send(value);
1810                    }
1811                }
1812            }
1813        }
1814    });
1815    receiver
1816}
1817
1818#[cfg(test)]
1819mod tests {
1820    use super::*;
1821
1822    /// Fake `claude --print --input-format stream-json` child. It appends every
1823    /// stdin frame to `$1` so a test can assert the exact bytes this adapter
1824    /// wrote, and replies with the control envelope the real CLI replies with.
1825    #[cfg(unix)]
1826    const FAKE_CLAUDE_ACKS: &str = r#"
1827cap="$1"
1828while IFS= read -r line; do
1829  printf '%s\n' "$line" >> "$cap"
1830  case "$line" in
1831    *'"subtype":"interrupt"'*)
1832      rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1833      printf '{"type":"system","subtype":"mid_flight"}\n'
1834      printf '{"type":"control_response","response":{"subtype":"success","request_id":"someone-elses-request","response":{}}}\n'
1835      printf '{"type":"control_response","response":{"subtype":"success","request_id":"%s","response":{"still_queued":[]}}}\n' "$rid"
1836      ;;
1837    *'"type":"user"'*)
1838      printf '{"type":"assistant","message":{"role":"assistant","content":"replied"}}\n'
1839      ;;
1840  esac
1841done
1842"#;
1843
1844    /// Same, but the control channel never answers — the hang this adapter must
1845    /// convert into a bounded, structured error.
1846    #[cfg(unix)]
1847    const FAKE_CLAUDE_NEVER_ACKS: &str = r#"
1848cap="$1"
1849while IFS= read -r line; do
1850  printf '%s\n' "$line" >> "$cap"
1851done
1852"#;
1853
1854    /// Raises the permission request the real CLI raises, byte-for-byte from
1855    /// `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`
1856    /// (claude 2.1.258, `--permission-prompt-tool stdio`), then blocks exactly
1857    /// as the CLI does: the tool result is emitted only once a
1858    /// `control_response` for that `request_id` arrives, and its content
1859    /// reports the `behavior` that was sent.
1860    #[cfg(unix)]
1861    const FAKE_CLAUDE_ASKS_PERMISSION: &str = r#"
1862cap="$1"
1863printf '{"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'
1864while IFS= read -r line; do
1865  printf '%s\n' "$line" >> "$cap"
1866  case "$line" in
1867    *'"request_id":"053f8a2d-3445-4011-a259-4261b31c7326"'*)
1868      case "$line" in
1869        *'"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' ;;
1870        *) printf '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_mock_1","content":"denied","is_error":true}]}}\n' ;;
1871      esac
1872      ;;
1873  esac
1874done
1875"#;
1876
1877    /// Rejects the interrupt the way the CLI reports a control failure.
1878    #[cfg(unix)]
1879    const FAKE_CLAUDE_REJECTS: &str = r#"
1880cap="$1"
1881while IFS= read -r line; do
1882  printf '%s\n' "$line" >> "$cap"
1883  case "$line" in
1884    *'"subtype":"interrupt"'*)
1885      rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1886      printf '{"type":"control_response","response":{"subtype":"error","request_id":"%s","error":"no active worker"}}\n' "$rid"
1887      ;;
1888  esac
1889done
1890"#;
1891
1892    #[cfg(unix)]
1893    struct FakeClaude {
1894        connection: ClaudeRuntimeConnection,
1895        capture: std::path::PathBuf,
1896        _dir: std::path::PathBuf,
1897    }
1898
1899    #[cfg(unix)]
1900    impl FakeClaude {
1901        async fn spawn(script: &str, control_timeout: Duration) -> Self {
1902            Self::spawn_with(script, control_timeout, CLAUDE_PERMISSION_RESPONSE_TIMEOUT).await
1903        }
1904
1905        async fn spawn_with(
1906            script: &str,
1907            control_timeout: Duration,
1908            permission_timeout: Duration,
1909        ) -> Self {
1910            let dir = std::env::temp_dir().join(format!(
1911                "supercode-fake-claude-{}-{}",
1912                std::process::id(),
1913                generated_session_id()
1914            ));
1915            std::fs::create_dir_all(&dir).unwrap();
1916            let capture = dir.join("stdin.jsonl");
1917            let launch = RuntimeLaunch {
1918                program: "/bin/sh".into(),
1919                arguments: vec![
1920                    "-c".into(),
1921                    script.into(),
1922                    "fake-claude".into(),
1923                    capture.display().to_string(),
1924                ],
1925                env: BTreeMap::new(),
1926            };
1927            let transport = RawLineTransport::spawn(&launch, Some(&dir), "claude-stream-json")
1928                .await
1929                .unwrap();
1930            let connection = ClaudeRuntimeConnection {
1931                handle: RuntimeHandle {
1932                    harness: HarnessId::from(HarnessId::CLAUDE_CODE),
1933                    runtime_id: "fake-session".into(),
1934                    endpoint: transport.endpoint.clone(),
1935                },
1936                transport,
1937                prefix: launch.clone(),
1938                cwd: dir.clone(),
1939                spoke: false,
1940                buffered_events: VecDeque::new(),
1941                next_control_request: 1,
1942                control_timeout,
1943                pending_permissions: Vec::new(),
1944                permission_timeout,
1945            };
1946            Self {
1947                connection,
1948                capture,
1949                _dir: dir,
1950            }
1951        }
1952
1953        fn written_frames(&self) -> Vec<Value> {
1954            std::fs::read_to_string(&self.capture)
1955                .unwrap_or_default()
1956                .lines()
1957                .filter(|line| !line.trim().is_empty())
1958                .map(|line| serde_json::from_str(line).expect("adapter wrote a non-JSON frame"))
1959                .collect()
1960        }
1961    }
1962
1963    #[cfg(unix)]
1964    #[tokio::test]
1965    async fn claude_interrupt_writes_one_control_request_per_call_with_a_fresh_id() {
1966        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1967
1968        fake.connection.interrupt().await.unwrap();
1969        fake.connection.interrupt().await.unwrap();
1970
1971        let frames = fake.written_frames();
1972        assert_eq!(
1973            frames.len(),
1974            2,
1975            "each interrupt must write exactly one control frame: {frames:?}"
1976        );
1977        let mut ids = Vec::new();
1978        for frame in &frames {
1979            assert_eq!(frame["type"], "control_request");
1980            assert_eq!(frame["request"]["subtype"], "interrupt");
1981            let id = frame["request_id"].as_str().expect("frame carries an id");
1982            assert!(!id.is_empty());
1983            ids.push(id.to_string());
1984        }
1985        assert_ne!(ids[0], ids[1], "request ids must be unique per call");
1986    }
1987
1988    /// The harness acknowledges an interrupt sent with no turn in flight —
1989    /// measured against claude 2.1.224, which replies `success` with an empty
1990    /// `still_queued` list and keeps taking input. The adapter reports that
1991    /// rather than inventing a turn-state gate, and the session stays usable.
1992    #[cfg(unix)]
1993    #[tokio::test]
1994    async fn claude_interrupt_with_no_turn_in_flight_is_acknowledged_and_the_session_survives() {
1995        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1996
1997        fake.connection.interrupt().await.unwrap();
1998        fake.connection
1999            .send_input(RuntimeInput {
2000                text: String::new(),
2001                image_urls: vec!["data:image/png;base64,aGVsbG8=".into()],
2002            })
2003            .await
2004            .unwrap();
2005
2006        // Events observed while the interrupt was pending are replayed first,
2007        // and the control channel's own frames never reach the consumer. Read
2008        // through the assistant event before inspecting the fake child's
2009        // capture so the child has necessarily consumed the user frame.
2010        let mut kinds = Vec::new();
2011        while kinds.len() < 2 {
2012            let event = fake.connection.next_event().await.unwrap().unwrap();
2013            assert_ne!(event.kind, "control_response");
2014            kinds.push(event.kind);
2015        }
2016        assert_eq!(kinds, vec!["system".to_string(), "assistant".to_string()]);
2017
2018        let frames = fake.written_frames();
2019        assert_eq!(frames[0]["type"], "control_request");
2020        assert_eq!(
2021            frames[1]["type"], "user",
2022            "a send issued after an interrupt must reach the harness, in order"
2023        );
2024        assert_eq!(
2025            frames[1]["message"]["content"][0]["source"],
2026            json!({"type":"base64", "media_type":"image/png", "data":"aGVsbG8="}),
2027            "an image-only turn must remain native without a synthetic text block"
2028        );
2029    }
2030
2031    #[cfg(unix)]
2032    #[tokio::test]
2033    async fn claude_interrupt_times_out_with_a_structured_error_instead_of_hanging() {
2034        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_NEVER_ACKS, Duration::from_millis(250)).await;
2035
2036        let started = std::time::Instant::now();
2037        let error = fake.connection.interrupt().await.unwrap_err();
2038
2039        assert!(
2040            started.elapsed() < Duration::from_secs(5),
2041            "interrupt must return on its own bound, not hang"
2042        );
2043        assert!(
2044            error
2045                .to_string()
2046                .contains("did not acknowledge the interrupt"),
2047            "unexpected error: {error}"
2048        );
2049        assert_eq!(fake.written_frames().len(), 1);
2050    }
2051
2052    #[cfg(unix)]
2053    #[tokio::test]
2054    async fn claude_interrupt_surfaces_a_rejecting_control_response_as_an_error() {
2055        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_REJECTS, Duration::from_secs(5)).await;
2056
2057        let error = fake.connection.interrupt().await.unwrap_err();
2058
2059        assert!(
2060            error.to_string().contains("no active worker"),
2061            "unexpected error: {error}"
2062        );
2063    }
2064
2065    #[test]
2066    fn claude_code_runtime_advertises_mid_turn_controls() {
2067        let capabilities = ClaudeCodeRuntimeBackend::new().capabilities();
2068        assert!(capabilities.interrupt);
2069        assert!(capabilities.steer);
2070        assert!(capabilities.respond_to_requests);
2071    }
2072
2073    /// ORC-2: the flag that makes the CLI raise its permission prompt on
2074    /// stdout instead of refusing the tool call outright. Measured against
2075    /// claude 2.1.258 — without it the same tool call comes back as
2076    /// `{"type":"system","subtype":"permission_denied"}` and no control
2077    /// request is ever written (receipt step `baseline_without_the_flag`).
2078    #[test]
2079    fn claude_code_launches_as_the_cli_permission_handler() {
2080        let backend = ClaudeCodeRuntimeBackend::new();
2081        let arguments = backend.launch.arguments.join(" ");
2082        assert!(
2083            arguments.contains("--permission-prompt-tool stdio"),
2084            "the default launch must register supercode as the permission handler: {arguments}"
2085        );
2086        assert!(arguments.contains("--input-format stream-json"));
2087        assert!(arguments.contains("--output-format stream-json"));
2088    }
2089
2090    /// ORC-2 dev/01 + dev/03: the whole permission loop on the adapter. The
2091    /// CLI's own `can_use_tool` frame reaches the event consumer, `respond`
2092    /// writes exactly the `control_response` the CLI accepts, and the blocked
2093    /// tool proceeds. Reverting the handler (dropping the `respond`
2094    /// implementation or the pending-request bookkeeping) makes this red.
2095    #[cfg(unix)]
2096    #[tokio::test]
2097    async fn claude_permission_request_surfaces_and_respond_allows_the_blocked_tool() {
2098        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2099
2100        let request = fake.connection.next_event().await.unwrap().unwrap();
2101        assert_eq!(request.kind, "control_request");
2102        assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
2103        let request_id = request.payload["request_id"].clone();
2104
2105        fake.connection
2106            .respond(request_id.clone(), json!({"behavior": "allow"}))
2107            .await
2108            .unwrap();
2109
2110        let result = fake.connection.next_event().await.unwrap().unwrap();
2111        assert_eq!(result.kind, "user");
2112        assert_eq!(
2113            result.payload["message"]["content"][0]["is_error"],
2114            json!(false),
2115            "the allowed tool must have run: {}",
2116            result.payload
2117        );
2118
2119        let frames = fake.written_frames();
2120        assert_eq!(frames.len(), 1, "one answer per request: {frames:?}");
2121        assert_eq!(
2122            frames[0],
2123            json!({
2124                "type": "control_response",
2125                "response": {
2126                    "subtype": "success",
2127                    "request_id": request_id,
2128                    "response": {"behavior": "allow"},
2129                },
2130            }),
2131            "the answer must be the envelope claude 2.1.258 accepts"
2132        );
2133    }
2134
2135    /// Deny blocks the tool, and the `message` the CLI's validator requires is
2136    /// filled in when the caller omits it — measured: claude 2.1.258 refuses a
2137    /// bare `{"behavior":"deny"}` as a schema-invalid permission result.
2138    #[cfg(unix)]
2139    #[tokio::test]
2140    async fn claude_permission_deny_blocks_the_tool_and_always_carries_a_message() {
2141        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2142
2143        let request = fake.connection.next_event().await.unwrap().unwrap();
2144        fake.connection
2145            .respond(
2146                request.payload["request_id"].clone(),
2147                json!({"behavior": "deny"}),
2148            )
2149            .await
2150            .unwrap();
2151
2152        let result = fake.connection.next_event().await.unwrap().unwrap();
2153        assert_eq!(
2154            result.payload["message"]["content"][0]["is_error"],
2155            json!(true),
2156            "a denied tool must not run: {}",
2157            result.payload
2158        );
2159
2160        let frames = fake.written_frames();
2161        let message = frames[0]["response"]["response"]["message"]
2162            .as_str()
2163            .expect("deny must carry a message");
2164        assert!(!message.is_empty(), "{frames:?}");
2165        assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2166    }
2167
2168    /// An answer that is not one of the two behaviors the protocol defines is
2169    /// refused by name rather than sent and silently dropped, and a request id
2170    /// nothing is waiting on is refused the same way.
2171    #[cfg(unix)]
2172    #[tokio::test]
2173    async fn claude_permission_answers_outside_the_protocol_are_refused_by_name() {
2174        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2175        let request = fake.connection.next_event().await.unwrap().unwrap();
2176        let request_id = request.payload["request_id"].clone();
2177
2178        let error = fake
2179            .connection
2180            .respond(request_id.clone(), json!({"outcome": "selected"}))
2181            .await
2182            .unwrap_err();
2183        assert!(error.to_string().contains("`allow`"), "{error}");
2184        assert!(error.to_string().contains("`deny`"), "{error}");
2185
2186        let error = fake
2187            .connection
2188            .respond(json!("not-a-live-request"), json!({"behavior": "allow"}))
2189            .await
2190            .unwrap_err();
2191        assert!(error.to_string().contains("not-a-live-request"), "{error}");
2192
2193        // The refusals wrote nothing, so the real request is still answerable.
2194        assert!(fake.written_frames().is_empty());
2195        fake.connection
2196            .respond(request_id, json!({"behavior": "allow"}))
2197            .await
2198            .unwrap();
2199        // Read through the tool result so the child has necessarily consumed
2200        // and captured the answer before its bytes are inspected.
2201        let result = fake.connection.next_event().await.unwrap().unwrap();
2202        assert_eq!(result.payload["message"]["content"][0]["is_error"], false);
2203        assert_eq!(fake.written_frames().len(), 1);
2204    }
2205
2206    /// The CLI blocks its turn forever on an unanswered request (measured: 20 s
2207    /// with no further frame), so the bound lives here: past the timeout the
2208    /// adapter denies on the caller's behalf and the turn moves on.
2209    #[cfg(unix)]
2210    #[tokio::test]
2211    async fn an_unanswered_claude_permission_request_is_denied_on_the_adapter_bound() {
2212        let mut fake = FakeClaude::spawn_with(
2213            FAKE_CLAUDE_ASKS_PERMISSION,
2214            Duration::from_secs(5),
2215            Duration::from_millis(250),
2216        )
2217        .await;
2218
2219        let request = fake.connection.next_event().await.unwrap().unwrap();
2220        assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
2221
2222        let result = tokio::time::timeout(Duration::from_secs(5), fake.connection.next_event())
2223            .await
2224            .expect("the adapter must deny on its own bound rather than hang")
2225            .unwrap()
2226            .unwrap();
2227        assert_eq!(
2228            result.payload["message"]["content"][0]["is_error"],
2229            json!(true),
2230            "an unanswered request must deny: {}",
2231            result.payload
2232        );
2233
2234        let frames = fake.written_frames();
2235        assert_eq!(frames.len(), 1, "{frames:?}");
2236        assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2237        assert!(frames[0]["response"]["response"]["message"]
2238            .as_str()
2239            .unwrap()
2240            .contains("timeout"));
2241    }
2242
2243    #[test]
2244    fn capability_reports_distinguish_resume_from_process_attach() {
2245        assert!(
2246            !PiRuntimeBackend::new()
2247                .capabilities()
2248                .attach_existing_process
2249        );
2250        assert!(
2251            !ClaudeCodeRuntimeBackend::new()
2252                .capabilities()
2253                .attach_existing_process
2254        );
2255        assert!(
2256            !OpenCodeRuntimeBackend::new()
2257                .capabilities()
2258                .attach_existing_process
2259        );
2260        assert!(
2261            OpenCodeRuntimeBackend::connect("http://127.0.0.1:4096")
2262                .capabilities()
2263                .attach_existing_process
2264        );
2265    }
2266
2267    #[test]
2268    fn generated_ids_are_uuid_shaped_and_unique() {
2269        let first = generated_session_id();
2270        let second = generated_session_id();
2271        assert_eq!(first.len(), 36);
2272        assert_ne!(first, second);
2273    }
2274
2275    #[test]
2276    fn opencode_event_session_id_covers_current_event_shapes() {
2277        assert_eq!(
2278            opencode_event_session_id(&json!({
2279                "type": "session.status",
2280                "properties": {"sessionID": "session-direct", "status": {"type": "busy"}}
2281            })),
2282            Some("session-direct")
2283        );
2284        assert_eq!(
2285            opencode_event_session_id(&json!({
2286                "type": "message.part.updated",
2287                "properties": {"part": {"sessionID": "session-part", "type": "text"}}
2288            })),
2289            Some("session-part")
2290        );
2291        assert_eq!(
2292            opencode_event_session_id(&json!({
2293                "type": "message.updated",
2294                "properties": {"info": {"sessionID": "session-info", "role": "assistant"}}
2295            })),
2296            Some("session-info")
2297        );
2298        assert_eq!(
2299            opencode_event_session_id(&json!({"type": "server.connected"})),
2300            None
2301        );
2302    }
2303
2304    #[tokio::test]
2305    async fn opencode_runtime_skips_events_for_other_sessions() {
2306        let (sender, receiver) = mpsc::unbounded_channel();
2307        sender
2308            .send(json!({
2309                "type": "session.idle",
2310                "properties": {"sessionID": "foreign-session"}
2311            }))
2312            .unwrap();
2313        sender
2314            .send(json!({
2315                "type": "message.part.delta",
2316                "properties": {"sessionID": "local-session", "delta": "hello"}
2317            }))
2318            .unwrap();
2319        let mut connection = OpenCodeRuntimeConnection {
2320            handle: RuntimeHandle {
2321                harness: HarnessId::from(HarnessId::OPENCODE),
2322                runtime_id: "local-session".into(),
2323                endpoint: RuntimeEndpoint::Http {
2324                    base_url: "http://127.0.0.1:1".into(),
2325                    protocol: "opencode-http".into(),
2326                },
2327            },
2328            base_url: "http://127.0.0.1:1".into(),
2329            cwd: "/tmp".into(),
2330            client: reqwest::Client::new(),
2331            receiver,
2332            child: None,
2333        };
2334
2335        let event = connection.next_event().await.unwrap().unwrap();
2336
2337        assert_eq!(event.kind, "message.part.delta");
2338        assert_eq!(event.payload["properties"]["sessionID"], "local-session");
2339    }
2340
2341    #[cfg(unix)]
2342    #[tokio::test]
2343    async fn opencode_shutdown_reaps_a_launcher_process_group() {
2344        let mut command = Command::new("/bin/sh");
2345        command
2346            .args(["-c", "sleep 30 & wait"])
2347            .stdin(Stdio::null())
2348            .stdout(Stdio::null())
2349            .stderr(Stdio::null())
2350            .kill_on_drop(true)
2351            .process_group(0);
2352        let mut child = command.spawn().unwrap();
2353        let pid = child.id().unwrap();
2354
2355        terminate_opencode_server(&mut child).await.unwrap();
2356
2357        assert!(child.try_wait().unwrap().is_some());
2358        let group_still_exists = unsafe { libc::kill(-(pid as libc::pid_t), 0) } == 0;
2359        assert!(
2360            !group_still_exists,
2361            "OpenCode worker process group survived close"
2362        );
2363    }
2364
2365    #[cfg(unix)]
2366    #[tokio::test]
2367    async fn opencode_shutdown_reaps_workers_after_launcher_exit() {
2368        let mut command = Command::new("/bin/sh");
2369        command
2370            .args(["-c", "sleep 30 & exit 0"])
2371            .stdin(Stdio::null())
2372            .stdout(Stdio::null())
2373            .stderr(Stdio::null())
2374            .kill_on_drop(true)
2375            .process_group(0);
2376        let mut child = command.spawn().unwrap();
2377        let pid = child.id().unwrap();
2378        tokio::time::sleep(Duration::from_millis(200)).await;
2379
2380        terminate_opencode_server(&mut child).await.unwrap();
2381
2382        assert!(child.try_wait().unwrap().is_some());
2383        assert!(
2384            !process_group_exists(pid),
2385            "OpenCode worker process group survived its exited launcher"
2386        );
2387    }
2388
2389    #[tokio::test]
2390    async fn opencode_health_probe_is_bounded_when_a_socket_never_responds() {
2391        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2392        let address = listener.local_addr().unwrap();
2393        let server = tokio::spawn(async move {
2394            let (_socket, _) = listener.accept().await.unwrap();
2395            tokio::time::sleep(Duration::from_secs(30)).await;
2396        });
2397        let started = tokio::time::Instant::now();
2398
2399        let error = wait_for_health_for(
2400            &reqwest::Client::new(),
2401            &format!("http://{address}"),
2402            Duration::from_millis(200),
2403        )
2404        .await
2405        .unwrap_err();
2406
2407        assert!(error.to_string().contains("health request timed out"));
2408        assert!(started.elapsed() < Duration::from_secs(1));
2409        server.abort();
2410    }
2411
2412    #[cfg(unix)]
2413    #[tokio::test]
2414    async fn acp_adapter_negotiates_starts_and_streams_without_blocking_prompt() {
2415        let script = r#"
2416            i=0
2417            while IFS= read -r line; do
2418              i=$((i + 1))
2419              case "$i" in
2420                1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
2421                2) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"acp_mock"}}' ;;
2422                3)
2423                  printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp_mock","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}'
2424                  printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2425                  ;;
2426              esac
2427            done
2428        "#;
2429        let backend = AcpRuntimeBackend::new(
2430            HarnessId::from("mock-acp"),
2431            RuntimeLaunch {
2432                program: "/bin/sh".into(),
2433                arguments: vec!["-c".into(), script.into()],
2434                env: BTreeMap::new(),
2435            },
2436        );
2437        let mut connection = backend
2438            .start(RuntimeStartRequest {
2439                cwd: std::env::current_dir().unwrap(),
2440                launch: None,
2441                mcp_servers: Vec::new(),
2442            })
2443            .await
2444            .unwrap();
2445        assert_eq!(connection.handle().runtime_id, "acp_mock");
2446        assert_eq!(
2447            connection
2448                .send_input(RuntimeInput {
2449                    text: "hi".into(),
2450                    image_urls: Vec::new(),
2451                })
2452                .await
2453                .unwrap()
2454                .as_deref(),
2455            Some("3")
2456        );
2457        assert_eq!(
2458            connection.next_event().await.unwrap().unwrap().kind,
2459            "session/update"
2460        );
2461        assert_eq!(
2462            connection.next_event().await.unwrap().unwrap().kind,
2463            "supercode/acp_request_completed"
2464        );
2465        connection.close().await.unwrap();
2466    }
2467
2468    /// ORC-6: the orchestrator mounts its per-session MCP server through the
2469    /// harness's own start door. On ACP that door is `session/new`'s
2470    /// `mcpServers`, which this backend used to hard-code to `[]`.
2471    #[cfg(unix)]
2472    #[tokio::test]
2473    async fn acp_start_forwards_mcp_servers_into_session_new() {
2474        let capture = std::env::temp_dir().join(format!(
2475            "supercode-acp-mcp-{}-{}.json",
2476            std::process::id(),
2477            std::time::SystemTime::now()
2478                .duration_since(std::time::UNIX_EPOCH)
2479                .unwrap()
2480                .as_nanos()
2481        ));
2482        let script = format!(
2483            r#"
2484            i=0
2485            while IFS= read -r line; do
2486              i=$((i + 1))
2487              case "$i" in
2488                1) printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":1,"agentCapabilities":{{}},"authMethods":[]}}}}' ;;
2489                2)
2490                  printf '%s\n' "$line" > {capture}
2491                  printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"sessionId":"acp_mock"}}}}'
2492                  ;;
2493              esac
2494            done
2495        "#,
2496            capture = capture.display()
2497        );
2498        let backend = AcpRuntimeBackend::new(
2499            HarnessId::from("mock-acp"),
2500            RuntimeLaunch {
2501                program: "/bin/sh".into(),
2502                arguments: vec!["-c".into(), script],
2503                env: BTreeMap::new(),
2504            },
2505        );
2506        let mut connection = backend
2507            .start(RuntimeStartRequest {
2508                cwd: std::env::current_dir().unwrap(),
2509                launch: None,
2510                mcp_servers: vec![McpServerLaunch {
2511                    name: "orchestrator".into(),
2512                    command: "/usr/bin/node".into(),
2513                    arguments: vec!["/tmp/server.mjs".into()],
2514                    env: BTreeMap::from([(
2515                        "SUPERCODE_ORCHESTRATOR_PROFILE".into(),
2516                        "coder".into(),
2517                    )]),
2518                }],
2519            })
2520            .await
2521            .unwrap();
2522        connection.close().await.unwrap();
2523
2524        let sent: Value =
2525            serde_json::from_str(&std::fs::read_to_string(&capture).unwrap()).unwrap();
2526        let _ = std::fs::remove_file(&capture);
2527        assert_eq!(sent["method"], "session/new");
2528        assert_eq!(
2529            sent["params"]["mcpServers"],
2530            json!([{
2531                "name": "orchestrator",
2532                "command": "/usr/bin/node",
2533                "args": ["/tmp/server.mjs"],
2534                "env": [{"name": "SUPERCODE_ORCHESTRATOR_PROFILE", "value": "coder"}],
2535            }])
2536        );
2537    }
2538
2539    #[cfg(unix)]
2540    #[tokio::test]
2541    async fn acp_uses_an_existing_login_before_trying_an_advertised_auth_method() {
2542        let script = r#"
2543            i=0
2544            while IFS= read -r line; do
2545              i=$((i + 1))
2546              if [ "$i" -eq 1 ]; then
2547                printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[{"id":"cached_token"}]}}'
2548              elif printf '%s' "$line" | grep -q 'session/new'; then
2549                printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"existing_login"}}'
2550              else
2551                exit 9
2552              fi
2553            done
2554        "#;
2555        let backend = AcpRuntimeBackend::new(
2556            HarnessId::from("mock-acp"),
2557            RuntimeLaunch {
2558                program: "/bin/sh".into(),
2559                arguments: vec!["-c".into(), script.into()],
2560                env: BTreeMap::new(),
2561            },
2562        );
2563        let mut connection = backend
2564            .start(RuntimeStartRequest {
2565                cwd: std::env::current_dir().unwrap(),
2566                launch: None,
2567                mcp_servers: Vec::new(),
2568            })
2569            .await
2570            .unwrap();
2571        assert_eq!(connection.handle().runtime_id, "existing_login");
2572        connection.close().await.unwrap();
2573    }
2574
2575    #[cfg(unix)]
2576    #[tokio::test]
2577    async fn known_acp_agent_reports_and_uses_load_session_for_resume() {
2578        let script = r#"
2579            i=0
2580            while IFS= read -r line; do
2581              i=$((i + 1))
2582              case "$i" in
2583                1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true},"authMethods":[]}}' ;;
2584                2)
2585                  case "$line" in
2586                    *'"method":"session/load"'*'"sessionId":"existing-session"'*)
2587                      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"historical replay"}}}}'
2588                      printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{}}'
2589                      ;;
2590                    *) exit 42 ;;
2591                  esac
2592                  ;;
2593                3)
2594                  printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fresh output"}}}}'
2595                  printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2596                  ;;
2597              esac
2598            done
2599        "#;
2600        let backend = AcpRuntimeBackend::new(
2601            HarnessId::from("known-acp"),
2602            RuntimeLaunch {
2603                program: "/bin/sh".into(),
2604                arguments: vec!["-c".into(), script.into()],
2605                env: BTreeMap::new(),
2606            },
2607        )
2608        .with_resume_support(true);
2609        assert!(backend.capabilities().resume_session);
2610        let mut connection = backend
2611            .attach(RuntimeAttachRequest {
2612                runtime_id: "existing-session".into(),
2613                cwd: Some(std::env::current_dir().unwrap()),
2614                launch: None,
2615            })
2616            .await
2617            .unwrap();
2618        assert_eq!(connection.handle().runtime_id, "existing-session");
2619        assert_eq!(
2620            connection
2621                .send_input(RuntimeInput {
2622                    text: "continue".into(),
2623                    image_urls: Vec::new(),
2624                })
2625                .await
2626                .unwrap()
2627                .as_deref(),
2628            Some("3")
2629        );
2630        let event = connection.next_event().await.unwrap().unwrap();
2631        assert_eq!(event.kind, "session/update");
2632        assert_eq!(
2633            event
2634                .payload
2635                .pointer("/params/update/content/text")
2636                .and_then(Value::as_str),
2637            Some("fresh output")
2638        );
2639        assert_eq!(
2640            connection.next_event().await.unwrap().unwrap().kind,
2641            "supercode/acp_request_completed"
2642        );
2643        connection.close().await.unwrap();
2644    }
2645}