Skip to main content

supercode_harness/runtime/
hosted.rs

1//! Multi-frontend host for one harness-native runtime connection.
2//!
3//! The native ACP/RPC/app-server process remains the single model loop. This
4//! host serializes control, broadcasts the lossless native events to SDK
5//! clients, and projects the same events onto Supercode's frontend contract
6//! so a terminal can join an editor-created session without resuming it.
7
8use std::collections::{HashMap, VecDeque};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex as StdMutex};
11
12use async_trait::async_trait;
13use serde_json::{json, Value};
14use tokio::sync::{broadcast, mpsc, oneshot};
15
16use super::{HarnessEvent, RuntimeCapabilities, RuntimeConnection, RuntimeHandle, RuntimeInput};
17use crate::frontend::{
18    FrontendActions, FrontendAttachment, FrontendConnectionState, FrontendDisplayCapabilities,
19    FrontendEvent, FrontendRuntime, FrontendRuntimeDescriptor, FrontendRuntimeError,
20    FrontendTurnState, FRONTEND_REPLAY_CAPACITY, FRONTEND_RUNTIME_SCHEMA_VERSION,
21};
22use crate::server::RuntimeSubmitError;
23use crate::{Error, FrontendResponse, Result};
24
25enum HostCommand {
26    Submit {
27        input: RuntimeInput,
28        reply: oneshot::Sender<Result<Option<String>>>,
29    },
30    Interrupt {
31        reply: oneshot::Sender<Result<()>>,
32    },
33    Steer {
34        text: String,
35        reply: oneshot::Sender<Result<()>>,
36    },
37    Respond {
38        request_id: Value,
39        response: Value,
40        reply: oneshot::Sender<Result<()>>,
41    },
42    Shutdown {
43        reply: oneshot::Sender<Result<()>>,
44    },
45}
46
47struct ProjectionState {
48    next_sequence: u64,
49    replay: VecDeque<FrontendEvent>,
50}
51
52/// Shared owner of a single harness-native runtime.
53pub struct HostedHarnessRuntime {
54    handle: RuntimeHandle,
55    capabilities: RuntimeCapabilities,
56    commands: mpsc::Sender<HostCommand>,
57    raw_events: broadcast::Sender<HarnessEvent>,
58    frontend_events: broadcast::Sender<FrontendEvent>,
59    projection: StdMutex<ProjectionState>,
60    /// Requests the translator has raised and nobody has answered yet, keyed
61    /// by the canonical id a frontend sees. BOTH doors — `frontend.v2.respond`
62    /// and the owner's `harness.v1.runtimes.respond` — funnel through
63    /// `respond_native`, which is where an entry is dropped and
64    /// `request_resolved` is published, so one request takes exactly one
65    /// answer and the door that did not answer reads the resolution.
66    pending_requests: StdMutex<HashMap<u64, Value>>,
67    busy: AtomicBool,
68    closed: AtomicBool,
69}
70
71impl HostedHarnessRuntime {
72    /// Promote one exclusive native connection into a multi-frontend host and
73    /// return the first SDK connection to it.
74    pub fn spawn(
75        runtime: Box<dyn RuntimeConnection>,
76        capabilities: RuntimeCapabilities,
77    ) -> (Arc<Self>, HostedHarnessConnection) {
78        let handle = runtime.handle().clone();
79        let (commands, command_rx) = mpsc::channel(32);
80        let (raw_events, raw_rx) = broadcast::channel(1024);
81        let (frontend_events, _) = broadcast::channel(1024);
82        let host = Arc::new(Self {
83            handle: handle.clone(),
84            capabilities,
85            commands,
86            raw_events,
87            frontend_events,
88            projection: StdMutex::new(ProjectionState {
89                next_sequence: 1,
90                replay: VecDeque::new(),
91            }),
92            pending_requests: StdMutex::new(HashMap::new()),
93            busy: AtomicBool::new(false),
94            closed: AtomicBool::new(false),
95        });
96        tokio::spawn(run_native_runtime(
97            runtime,
98            Arc::downgrade(&host),
99            command_rx,
100        ));
101        let connection = HostedHarnessConnection {
102            host: host.clone(),
103            handle,
104            events: raw_rx,
105            closed: false,
106        };
107        (host, connection)
108    }
109
110    /// Subscribe to the canonical sequenced frontend event stream.
111    pub fn frontend_sender(&self) -> broadcast::Sender<FrontendEvent> {
112        self.frontend_events.clone()
113    }
114
115    /// Shut down the one native process owned by this host.
116    pub async fn shutdown(&self) -> Result<()> {
117        if self.closed.load(Ordering::SeqCst) {
118            return Ok(());
119        }
120        let (reply, response) = oneshot::channel();
121        self.commands
122            .send(HostCommand::Shutdown { reply })
123            .await
124            .map_err(|_| Error::Other("hosted harness runtime is closed".into()))?;
125        response
126            .await
127            .map_err(|_| Error::Other("hosted harness runtime stopped before shutdown".into()))?
128    }
129
130    fn claim_submit(&self) -> Result<()> {
131        if self.closed.load(Ordering::SeqCst) {
132            return Err(Error::Other("hosted harness runtime is closed".into()));
133        }
134        if self
135            .busy
136            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
137            .is_err()
138        {
139            return Err(Error::Other("a harness turn is already in progress".into()));
140        }
141        Ok(())
142    }
143
144    async fn submit_native_claimed(&self, input: RuntimeInput) -> Result<Option<String>> {
145        self.publish(json!({"type":"user_message", "text":input.text}));
146        self.publish(json!({"type":"turn_started"}));
147        let (reply, response) = oneshot::channel();
148        if self
149            .commands
150            .send(HostCommand::Submit { input, reply })
151            .await
152            .is_err()
153        {
154            self.busy.store(false, Ordering::SeqCst);
155            self.publish(
156                json!({"type":"turn_failed", "message":"Hosted harness runtime is closed."}),
157            );
158            self.publish(json!({"type":"turn_completed"}));
159            self.mark_closed("Harness runtime command channel closed.");
160            return Err(Error::Other("hosted harness runtime is closed".into()));
161        }
162        match response.await {
163            Ok(Ok(turn)) => Ok(turn),
164            Ok(Err(error)) => {
165                self.busy.store(false, Ordering::SeqCst);
166                self.publish(json!({"type":"turn_failed", "message":error.to_string()}));
167                self.publish(json!({"type":"turn_completed"}));
168                Err(error)
169            }
170            Err(_) => {
171                self.busy.store(false, Ordering::SeqCst);
172                self.publish(json!({"type":"turn_failed", "message":"Hosted harness runtime stopped before accepting input."}));
173                self.publish(json!({"type":"turn_completed"}));
174                self.mark_closed("Harness runtime stopped before accepting input.");
175                Err(Error::Other(
176                    "hosted harness runtime stopped before accepting input".into(),
177                ))
178            }
179        }
180    }
181
182    async fn submit_native(&self, text: String) -> Result<Option<String>> {
183        self.claim_submit()?;
184        self.submit_native_claimed(RuntimeInput {
185            text,
186            image_urls: Vec::new(),
187        })
188        .await
189    }
190
191    async fn interrupt_native(&self) -> Result<()> {
192        let (reply, response) = oneshot::channel();
193        self.commands
194            .send(HostCommand::Interrupt { reply })
195            .await
196            .map_err(|_| Error::Other("hosted harness runtime is closed".into()))?;
197        response
198            .await
199            .map_err(|_| Error::Other("hosted harness runtime stopped before interrupt".into()))?
200    }
201
202    async fn steer_native(&self, text: String) -> Result<()> {
203        let (reply, response) = oneshot::channel();
204        self.commands
205            .send(HostCommand::Steer { text, reply })
206            .await
207            .map_err(|_| Error::Other("hosted harness runtime is closed".into()))?;
208        response
209            .await
210            .map_err(|_| Error::Other("hosted harness runtime stopped before steering".into()))?
211    }
212
213    async fn respond_native(&self, request_id: Value, response: Value) -> Result<()> {
214        self.respond_native_as(request_id, response, None).await
215    }
216
217    /// Answer one native request. `canonical` is the portable response when a
218    /// frontend answered; the owner's door passes `None` and the published
219    /// resolution carries the native body it actually sent.
220    async fn respond_native_as(
221        &self,
222        request_id: Value,
223        response: Value,
224        canonical: Option<Value>,
225    ) -> Result<()> {
226        let (reply, completed) = oneshot::channel();
227        self.commands
228            .send(HostCommand::Respond {
229                request_id: request_id.clone(),
230                response: response.clone(),
231                reply,
232            })
233            .await
234            .map_err(|_| Error::Other("hosted harness runtime is closed".into()))?;
235        completed
236            .await
237            .map_err(|_| Error::Other("hosted harness runtime stopped before response".into()))??;
238        // Only a request the native runtime ACCEPTED is resolved, and only
239        // once: the first door through here removes it, so a second answer
240        // finds nothing to publish.
241        let resolved = {
242            let mut pending = self
243                .pending_requests
244                .lock()
245                .unwrap_or_else(std::sync::PoisonError::into_inner);
246            let found = pending
247                .iter()
248                .find(|(_, native)| *native == &request_id)
249                .map(|(id, _)| *id);
250            found.and_then(|id| pending.remove(&id).map(|_| id))
251        };
252        if let Some(id) = resolved {
253            self.publish(json!({
254                "type": "request_resolved",
255                "request_id": id,
256                "response": canonical.unwrap_or(response),
257            }));
258        }
259        Ok(())
260    }
261
262    /// The native id a canonical request maps to, while it is still open.
263    fn native_request_id(&self, request_id: u64) -> Option<Value> {
264        self.pending_requests
265            .lock()
266            .unwrap_or_else(std::sync::PoisonError::into_inner)
267            .get(&request_id)
268            .cloned()
269    }
270
271    fn publish(&self, payload: Value) {
272        let event = {
273            let mut projection = self
274                .projection
275                .lock()
276                .unwrap_or_else(std::sync::PoisonError::into_inner);
277            let event = FrontendEvent::new(projection.next_sequence, payload);
278            projection.next_sequence = projection.next_sequence.saturating_add(1);
279            projection.replay.push_back(event.clone());
280            while projection.replay.len() > FRONTEND_REPLAY_CAPACITY {
281                projection.replay.pop_front();
282            }
283            event
284        };
285        let _ = self.frontend_events.send(event);
286    }
287
288    fn accept_native_event(&self, event: HarnessEvent) {
289        let _ = self.raw_events.send(event.clone());
290        for payload in project_native_event(self.handle.harness.as_str(), &event) {
291            let terminal = matches!(
292                payload.get("type").and_then(Value::as_str),
293                Some("turn_succeeded" | "turn_interrupted" | "turn_failed")
294            );
295            if terminal {
296                self.busy.store(false, Ordering::SeqCst);
297            }
298            if payload.get("type").and_then(Value::as_str) == Some("request") {
299                if let (Some(id), Some(native)) = (
300                    payload.pointer("/request/id").and_then(Value::as_u64),
301                    payload
302                        .pointer("/request/payload/native_request_id")
303                        .cloned(),
304                ) {
305                    self.pending_requests
306                        .lock()
307                        .unwrap_or_else(std::sync::PoisonError::into_inner)
308                        .insert(id, native);
309                }
310            }
311            self.publish(payload);
312        }
313    }
314
315    fn mark_closed(&self, message: impl Into<String>) {
316        if self.closed.swap(true, Ordering::SeqCst) {
317            return;
318        }
319        let message = message.into();
320        self.busy.store(false, Ordering::SeqCst);
321        // The raw SDK connection and the portable frontend must observe the
322        // same terminal edge. Keeping the sender alive inside `self` otherwise
323        // leaves the SDK receiver waiting forever after native EOF.
324        let _ = self.raw_events.send(HarnessEvent {
325            sequence: None,
326            kind: "transport_closed".into(),
327            payload: json!({"message":message, "terminal":true}),
328        });
329        self.publish(json!({"type":"runtime_disconnected", "message":message}));
330    }
331
332    fn descriptor(&self) -> FrontendRuntimeDescriptor {
333        FrontendRuntimeDescriptor {
334            schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
335            session_id: self.handle.runtime_id.clone(),
336            source_harness: Some(self.handle.harness.as_str().to_string()),
337            emulation_profile: None,
338            active_modules: Vec::new(),
339            commands: Vec::new(),
340            operations: Vec::new(),
341            actions: FrontendActions {
342                submit: self.capabilities.send_input,
343                interrupt: self.capabilities.interrupt,
344                steer: self.capabilities.steer,
345                // ANSWERABLE where this harness has a reply translation, and
346                // only there. `project_native_event` turns a harness's native
347                // events into the canonical stream;
348                // `hosted_permission_reply` is its mirror, turning a canonical
349                // decision back into that harness's own reply envelope — so a
350                // portable frontend can answer a request without ever seeing
351                // the native shape. Today that is Claude Code, whose
352                // permission-prompt-tool protocol takes
353                // `{behavior:'allow'}` / `{behavior:'deny', message}`; a
354                // harness with no translation keeps `false` and its requests
355                // stay observe-only, which is what every other hosted harness
356                // still is. The request event states which decisions its own
357                // protocol accepts, so a frontend offers exactly those.
358                //
359                // The owner's `harness.v1.approvals.resolve` is unchanged and
360                // still authoritative: both doors funnel through
361                // `respond_native_as`, which drops the pending entry and
362                // publishes `request_resolved` once, so one request takes one
363                // answer and whichever door answered first wins.
364                respond: !hosted_answerable_decisions(self.handle.harness.as_str()).is_empty(),
365                detach: true,
366                close: false,
367            },
368            display: FrontendDisplayCapabilities {
369                event_kinds: vec![
370                    "user_message".into(),
371                    "turn_started".into(),
372                    "turn_succeeded".into(),
373                    "turn_interrupted".into(),
374                    "turn_failed".into(),
375                    "text_delta".into(),
376                    "reasoning".into(),
377                    "tool_call_started".into(),
378                    "tool_call_completed".into(),
379                    // Observation only — `actions.respond` stays false, so a
380                    // frontend shows the request and no answer control.
381                    "request".into(),
382                    "native_event".into(),
383                    "runtime_disconnected".into(),
384                ],
385                opaque_fallback: true,
386            },
387            model: self.handle.harness.as_str().to_string(),
388            turn_state: if self.busy.load(Ordering::SeqCst) {
389                FrontendTurnState::Busy
390            } else {
391                FrontendTurnState::Idle
392            },
393            connection_state: if self.closed.load(Ordering::SeqCst) {
394                FrontendConnectionState::ShuttingDown
395            } else {
396                FrontendConnectionState::Connected
397            },
398            extensions: Default::default(),
399        }
400    }
401}
402
403#[async_trait]
404impl FrontendRuntime for HostedHarnessRuntime {
405    async fn describe(
406        &self,
407    ) -> std::result::Result<FrontendRuntimeDescriptor, FrontendRuntimeError> {
408        Ok(self.descriptor())
409    }
410
411    async fn attach(
412        &self,
413        _history_limit: usize,
414    ) -> std::result::Result<FrontendAttachment, FrontendRuntimeError> {
415        let live = self.frontend_events.subscribe();
416        let projection = self
417            .projection
418            .lock()
419            .unwrap_or_else(std::sync::PoisonError::into_inner);
420        let replay = projection.replay.clone();
421        if let Some(first) = replay.front() {
422            if first.sequence > 1 {
423                return Err(FrontendRuntimeError::ReplayGap(first.sequence - 1));
424            }
425        }
426        Ok(FrontendAttachment::new(
427            self.descriptor(),
428            Vec::new(),
429            0,
430            replay,
431            live,
432            None,
433        ))
434    }
435
436    async fn send_input(
437        self: Arc<Self>,
438        prompt: String,
439    ) -> std::result::Result<(), FrontendRuntimeError> {
440        self.claim_submit().map_err(hosted_submit_error)?;
441        tokio::spawn(async move {
442            let _ = self
443                .submit_native_claimed(RuntimeInput {
444                    text: prompt,
445                    image_urls: Vec::new(),
446                })
447                .await;
448        });
449        Ok(())
450    }
451
452    async fn send_input_with_images(
453        self: Arc<Self>,
454        prompt: String,
455        image_urls: Vec<String>,
456    ) -> std::result::Result<(), FrontendRuntimeError> {
457        self.claim_submit().map_err(hosted_submit_error)?;
458        tokio::spawn(async move {
459            let _ = self
460                .submit_native_claimed(RuntimeInput {
461                    text: prompt,
462                    image_urls,
463                })
464                .await;
465        });
466        Ok(())
467    }
468
469    async fn submit(&self, prompt: String) -> std::result::Result<String, FrontendRuntimeError> {
470        self.submit_native(prompt)
471            .await
472            .map(|turn| turn.unwrap_or_default())
473            .map_err(hosted_submit_error)
474    }
475
476    async fn interrupt(&self) -> std::result::Result<bool, FrontendRuntimeError> {
477        if !self.busy.load(Ordering::SeqCst) {
478            return Ok(false);
479        }
480        self.interrupt_native()
481            .await
482            .map_err(|error| FrontendRuntimeError::Transport(error.to_string()))?;
483        // Acceptance of the interrupt request does not mean the native turn
484        // has stopped. Keep the shared runtime busy until its terminal event,
485        // so another frontend cannot race a prompt into turn teardown.
486        Ok(true)
487    }
488
489    async fn steer(&self, prompt: String) -> std::result::Result<(), FrontendRuntimeError> {
490        if !self.busy.load(Ordering::SeqCst) || !self.capabilities.steer {
491            return Err(FrontendRuntimeError::UnsupportedAction("steer"));
492        }
493        self.steer_native(prompt)
494            .await
495            .map_err(|error| FrontendRuntimeError::Transport(error.to_string()))
496    }
497
498    async fn respond(
499        &self,
500        response: FrontendResponse,
501    ) -> std::result::Result<(), FrontendRuntimeError> {
502        let harness = self.handle.harness.as_str();
503        let FrontendResponse::Approval {
504            request_id,
505            decision,
506        } = &response
507        else {
508            return Err(FrontendRuntimeError::UnsupportedAction(
509                "respond: a hosted harness answers approval requests only",
510            ));
511        };
512        let Some(native_id) = self.native_request_id(*request_id) else {
513            return Err(FrontendRuntimeError::UnknownRequest(*request_id));
514        };
515        let body = hosted_permission_reply(harness, *decision)
516            .map_err(FrontendRuntimeError::InvalidResponse)?;
517        let canonical = serde_json::to_value(&response).unwrap_or(Value::Null);
518        self.respond_native_as(native_id, body, Some(canonical))
519            .await
520            .map_err(|error| FrontendRuntimeError::Execution {
521                operation: crate::SdkOperation::Respond,
522                message: error.to_string(),
523            })
524    }
525}
526
527fn hosted_submit_error(error: Error) -> FrontendRuntimeError {
528    if error.to_string().contains("already in progress") {
529        FrontendRuntimeError::Submit(RuntimeSubmitError::Busy)
530    } else {
531        FrontendRuntimeError::Transport(error.to_string())
532    }
533}
534
535/// SDK connection to a hosted native runtime. Closing it shuts down the
536/// owner runtime; terminal HTTP attachments are non-owning frontend leases.
537pub struct HostedHarnessConnection {
538    host: Arc<HostedHarnessRuntime>,
539    handle: RuntimeHandle,
540    events: broadcast::Receiver<HarnessEvent>,
541    closed: bool,
542}
543
544#[async_trait]
545impl RuntimeConnection for HostedHarnessConnection {
546    fn handle(&self) -> &RuntimeHandle {
547        &self.handle
548    }
549
550    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
551        self.host.claim_submit()?;
552        self.host.submit_native_claimed(input).await
553    }
554
555    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
556        match self.events.recv().await {
557            Ok(event) => Ok(Some(event)),
558            Err(broadcast::error::RecvError::Lagged(count)) => Err(Error::Other(format!(
559                "hosted harness event stream lost {count} event(s)"
560            ))),
561            Err(broadcast::error::RecvError::Closed) => Ok(None),
562        }
563    }
564
565    async fn interrupt(&mut self) -> Result<()> {
566        self.host.interrupt_native().await
567    }
568
569    async fn steer(&mut self, text: String) -> Result<()> {
570        self.host.steer_native(text).await
571    }
572
573    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
574        self.host.respond_native(request_id, response).await
575    }
576
577    async fn close(&mut self) -> Result<()> {
578        if self.closed {
579            return Ok(());
580        }
581        self.host.shutdown().await?;
582        self.closed = true;
583        Ok(())
584    }
585}
586
587async fn run_native_runtime(
588    mut runtime: Box<dyn RuntimeConnection>,
589    host: std::sync::Weak<HostedHarnessRuntime>,
590    mut commands: mpsc::Receiver<HostCommand>,
591) {
592    loop {
593        tokio::select! {
594            command = commands.recv() => {
595                let Some(command) = command else {
596                    let _ = runtime.close().await;
597                    return;
598                };
599                match command {
600                    HostCommand::Submit { input, reply } => {
601                        let _ = reply.send(runtime.send_input(input).await);
602                    }
603                    HostCommand::Interrupt { reply } => {
604                        let _ = reply.send(runtime.interrupt().await);
605                    }
606                    HostCommand::Steer { text, reply } => {
607                        let _ = reply.send(runtime.steer(text).await);
608                    }
609                    HostCommand::Respond { request_id, response, reply } => {
610                        let _ = reply.send(runtime.respond(request_id, response).await);
611                    }
612                    HostCommand::Shutdown { reply } => {
613                        let result = runtime.close().await;
614                        let closed = result.is_ok();
615                        let _ = reply.send(result);
616                        if closed {
617                            if let Some(host) = host.upgrade() {
618                                host.mark_closed("Harness runtime closed.");
619                            }
620                            return;
621                        }
622                    }
623                }
624            }
625            event = runtime.next_event() => {
626                let Some(host) = host.upgrade() else {
627                    let _ = runtime.close().await;
628                    return;
629                };
630                match event {
631                    Ok(Some(event)) => host.accept_native_event(event),
632                    Ok(None) => {
633                        host.mark_closed("Harness runtime transport closed.");
634                        return;
635                    }
636                    Err(error) => {
637                        host.mark_closed(error.to_string());
638                        return;
639                    }
640                }
641            }
642        }
643    }
644}
645
646/// Which harnesses can answer a `request` through the PORTABLE door, and the
647/// decisions each one's own protocol accepts.
648///
649/// This is the mirror of `project_native_event`: that side translates a
650/// harness's native events into the canonical stream, this side translates a
651/// canonical decision back into the harness's own reply envelope. A harness
652/// with no entry here keeps `respond: false` and its request stays
653/// observe-only.
654pub(crate) fn hosted_answerable_decisions(harness: &str) -> &'static [&'static str] {
655    match harness {
656        // Claude Code's permission-prompt-tool protocol, as its own validator
657        // states it: `{behavior:'allow', updatedInput?:object}` or
658        // `{behavior:'deny', message:string}`
659        // (docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json).
660        // There is no always-allow BEHAVIOR — the CLI carries session
661        // permission through a separate `updatedPermissions` field — so
662        // `allow_for_session` is not offered rather than quietly downgraded to
663        // a once-allow the person did not choose. `crates/harness/src/approvals.rs`
664        // refuses it by name on the owner's door for the same reason.
665        "claude-code" => &["allow", "deny"],
666        _ => &[],
667    }
668}
669
670/// Translate one canonical approval decision into the harness's own envelope.
671fn hosted_permission_reply(
672    harness: &str,
673    decision: crate::FrontendApprovalDecision,
674) -> std::result::Result<Value, String> {
675    use crate::FrontendApprovalDecision as Decision;
676    let accepted = hosted_answerable_decisions(harness);
677    let wire = match decision {
678        Decision::Allow => "allow",
679        Decision::AllowForSession => "allow_for_session",
680        Decision::Deny => "deny",
681    };
682    if !accepted.contains(&wire) {
683        return Err(format!(
684            "`{wire}` is not a decision a hosted {harness} runtime can carry; this harness accepts \
685             {}",
686            if accepted.is_empty() {
687                "no portable decision — its requests are observe-only".to_string()
688            } else {
689                accepted
690                    .iter()
691                    .map(|decision| format!("`{decision}`"))
692                    .collect::<Vec<_>>()
693                    .join(" or ")
694            },
695        ));
696    }
697    match harness {
698        "claude-code" => Ok(match decision {
699            Decision::Allow => json!({"behavior": "allow"}),
700            // Measured: the CLI's validator rejects a `deny` with no message.
701            Decision::Deny => json!({
702                "behavior": "deny",
703                "message": "denied through the supercode frontend",
704            }),
705            Decision::AllowForSession => unreachable!("refused above"),
706        }),
707        _ => Err(format!(
708            "no hosted permission reply is defined for {harness}"
709        )),
710    }
711}
712
713fn project_native_event(harness: &str, event: &HarnessEvent) -> Vec<Value> {
714    let key = event.kind.to_ascii_lowercase().replace('-', "_");
715    let payload = &event.payload;
716    if key == "transport_closed" {
717        return vec![
718            json!({"type":"runtime_disconnected", "message":extract_text(payload).unwrap_or_else(|| "Harness runtime transport closed.".into())}),
719        ];
720    }
721    if key == "transport_error" || key == "error" {
722        return vec![
723            json!({"type":"turn_failed", "message":extract_text(payload).unwrap_or_else(|| "Harness runtime failed.".into()), "raw":payload}),
724        ];
725    }
726    if key == "session/update" {
727        let update = payload
728            .pointer("/params/update")
729            .or_else(|| payload.get("update"))
730            .unwrap_or(payload);
731        let update_kind = update
732            .get("sessionUpdate")
733            .or_else(|| update.get("type"))
734            .and_then(Value::as_str)
735            .unwrap_or_default()
736            .to_ascii_lowercase();
737        if update_kind == "agent_message_chunk" {
738            return vec![
739                json!({"type":"text_delta", "text":extract_text(update.get("content").unwrap_or(update)).unwrap_or_default(), "raw":payload}),
740            ];
741        }
742        if update_kind == "agent_thought_chunk" {
743            return vec![
744                json!({"type":"reasoning", "text":extract_text(update.get("content").unwrap_or(update)).unwrap_or_default(), "raw":payload}),
745            ];
746        }
747        if matches!(update_kind.as_str(), "tool_call" | "tool_call_update") {
748            return vec![project_tool(update, payload)];
749        }
750    }
751    if key == "supercode/acp_request_completed" {
752        let failure = payload
753            .pointer("/params/error")
754            .or_else(|| payload.get("error"));
755        return completion(failure.and_then(extract_text));
756    }
757    match harness {
758        "codex" => project_codex(&key, payload),
759        "claude-code" => project_claude(&key, payload),
760        "pi" => project_pi(&key, payload),
761        "opencode" => project_opencode(&key, payload),
762        _ => project_generic(&key, payload),
763    }
764}
765
766fn project_codex(key: &str, payload: &Value) -> Vec<Value> {
767    if key == "turn/started" {
768        return vec![native_payload(key, payload)];
769    }
770    if key == "turn/completed" {
771        let status = payload
772            .pointer("/params/turn/status")
773            .or_else(|| payload.pointer("/turn/status"))
774            .and_then(Value::as_str)
775            .unwrap_or("completed")
776            .to_ascii_lowercase();
777        return completion(
778            (status.contains("fail") || status.contains("error") || status.contains("cancel"))
779                .then(|| extract_text(payload).unwrap_or_else(|| status.clone())),
780        );
781    }
782    if key.ends_with("/delta") {
783        let text = payload
784            .pointer("/params/delta")
785            .or_else(|| payload.get("delta"))
786            .and_then(extract_text)
787            .or_else(|| extract_text(payload))
788            .unwrap_or_default();
789        return vec![
790            json!({"type":if key.contains("reasoning") { "reasoning" } else { "text_delta" }, "text":text, "raw":payload}),
791        ];
792    }
793    if key.contains("commandexecution")
794        || key.contains("mcptool")
795        || key.contains("filechange")
796        || key.contains("tool")
797    {
798        let source = payload
799            .pointer("/params/item")
800            .or_else(|| payload.get("item"))
801            .unwrap_or(payload);
802        return vec![project_tool(source, payload)];
803    }
804    vec![native_payload(key, payload)]
805}
806
807/// Claude Code's stream-json, as `ClaudeCodeBackend` launches it: `--print
808/// --input-format stream-json --output-format stream-json --verbose
809/// --permission-prompt-tool stdio` (`crates/harness/src/runtime/adapters.rs`).
810///
811/// That argv carries no `--include-partial-messages`, so nothing arrives as a
812/// `stream_event`: a turn is whole `assistant` and `user` events whose
813/// `message.content` is the Anthropic content-block array, plus `system`,
814/// `control_request` and a final `result`. The blocks are where the turn
815/// actually lives — text, thinking, `tool_use`, and `tool_result` — so a
816/// projector that reads only the envelope sees a turn with no tools in it and
817/// leaves every one of them to the `native_event` fallback. This walks them.
818fn project_claude(key: &str, payload: &Value) -> Vec<Value> {
819    if key == "result" {
820        let failed = payload.get("is_error").and_then(Value::as_bool) == Some(true)
821            || payload
822                .get("subtype")
823                .and_then(Value::as_str)
824                .is_some_and(|subtype| subtype != "success");
825        return completion(
826            failed.then(|| extract_text(payload).unwrap_or_else(|| "Claude turn failed.".into())),
827        );
828    }
829    if key == "stream_event" {
830        // Only reachable when a caller adds `--include-partial-messages`. The
831        // whole-message `assistant` event still follows, so text would arrive
832        // twice; the partial path deliberately projects ONLY what the whole
833        // message cannot carry on its own — nothing today.
834        let stream = payload
835            .get("event")
836            .or_else(|| payload.get("stream_event"))
837            .unwrap_or(payload);
838        if stream.get("type").and_then(Value::as_str) == Some("content_block_delta") {
839            return vec![native_payload(key, payload)];
840        }
841    }
842    if key == "assistant" {
843        return project_claude_blocks(payload, true);
844    }
845    if key == "user" {
846        return project_claude_blocks(payload, false);
847    }
848    if key == "control_request" {
849        return project_claude_control_request(payload);
850    }
851    if key == "system" {
852        // `init` is the CLI announcing its own tools, model and cwd. It is
853        // session setup, not transcript, and a reader has no use for it.
854        if payload.get("subtype").and_then(Value::as_str) == Some("init") {
855            return Vec::new();
856        }
857    }
858    if key == "rate_limit_event" {
859        // Quota telemetry the CLI emits mid-turn. `allowed`/`allowed_warning`
860        // is nothing a reader can act on and lands in the middle of the
861        // assistant's own sentences; a `rejected` status is a real refusal, so
862        // it falls through and stays visible.
863        let status = payload
864            .pointer("/rate_limit_info/status")
865            .and_then(Value::as_str)
866            .unwrap_or_default();
867        if status.starts_with("allowed") {
868            return Vec::new();
869        }
870    }
871    project_generic(key, payload)
872}
873
874/// The Anthropic content-block array an `assistant` or `user` event carries.
875fn claude_blocks(payload: &Value) -> Option<&Vec<Value>> {
876    payload
877        .pointer("/message/content")
878        .or_else(|| payload.get("content"))
879        .and_then(Value::as_array)
880}
881
882fn project_claude_blocks(payload: &Value, assistant: bool) -> Vec<Value> {
883    let Some(blocks) = claude_blocks(payload) else {
884        // A content-less envelope (or a plain string content) keeps the old
885        // whole-payload reading rather than vanishing.
886        let text = extract_text(payload).unwrap_or_default();
887        if text.is_empty() {
888            return vec![native_payload(
889                if assistant { "assistant" } else { "user" },
890                payload,
891            )];
892        }
893        return vec![
894            json!({"type":if assistant { "text_delta" } else { "user_message" }, "text":text, "raw":payload}),
895        ];
896    };
897    let mut projected = Vec::new();
898    for block in blocks {
899        match block
900            .get("type")
901            .and_then(Value::as_str)
902            .unwrap_or_default()
903        {
904            "text" => {
905                let text = block
906                    .get("text")
907                    .and_then(Value::as_str)
908                    .unwrap_or_default();
909                if !text.is_empty() {
910                    projected.push(json!({
911                        "type": if assistant { "text_delta" } else { "user_message" },
912                        "text": text,
913                        "raw": block,
914                    }));
915                }
916            }
917            "thinking" | "redacted_thinking" => {
918                let text = extract_text(block).unwrap_or_default();
919                if !text.is_empty() {
920                    projected.push(json!({"type":"reasoning", "text":text, "raw":block}));
921                }
922            }
923            "tool_use" => projected.push(json!({
924                "type": "tool_call_started",
925                "id": block.get("id").cloned().unwrap_or(Value::Null),
926                "name": block.get("name").cloned().unwrap_or(Value::Null),
927                "arguments": block.get("input").map(Value::to_string).unwrap_or_default(),
928                "raw": block,
929            })),
930            "tool_result" => projected.push(json!({
931                // The result names only the call it answers — the tool's own
932                // name was stated when it started, and a frontend correlates
933                // the pair by that id.
934                "type": "tool_call_completed",
935                "id": block.get("tool_use_id").cloned().unwrap_or(Value::Null),
936                "name": Value::Null,
937                "output": extract_text(block.get("content").unwrap_or(block)).unwrap_or_default(),
938                "is_error": block.get("is_error").and_then(Value::as_bool).unwrap_or(false),
939                "raw": block,
940            })),
941            _ => projected.push(native_payload(
942                if assistant { "assistant" } else { "user" },
943                block,
944            )),
945        }
946    }
947    projected
948}
949
950/// Claude Code's `can_use_tool` control request as the canonical `request`.
951///
952/// OBSERVATION ONLY, and deliberately: the hosted descriptor advertises
953/// `respond: false` because the portable contract cannot carry this harness's
954/// native response envelope — the owner/editor answers it through
955/// `harness.v1.approvals.resolve`. Emitting it is what that same decision
956/// allows ("a terminal may observe requests"), and a frontend renders controls
957/// only for actions the descriptor advertises, so no answer button appears.
958fn project_claude_control_request(payload: &Value) -> Vec<Value> {
959    let request = payload.get("request").unwrap_or(payload);
960    if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
961        return vec![native_payload("control_request", payload)];
962    }
963    let native_id = payload
964        .get("request_id")
965        .and_then(Value::as_str)
966        .unwrap_or_default();
967    vec![json!({
968        "type": "request",
969        "request": {
970            "id": claude_request_id(native_id),
971            "kind": "approval",
972            "payload": {
973                "tool": request.get("tool_name").or_else(|| request.get("toolName")).cloned().unwrap_or(Value::Null),
974                "arguments": request.get("input").cloned().unwrap_or(Value::Null),
975                "native_request_id": native_id,
976                // What this harness's own protocol can carry, so a frontend
977                // offers exactly those answers and no button that would be
978                // refused.
979                "decisions": hosted_answerable_decisions("claude-code"),
980            },
981        },
982    })]
983}
984
985/// A stable JSON-safe number for a native request id that is a string.
986///
987/// The contract's `FrontendRequest.id` is numeric and Claude Code's
988/// `request_id` is a uuid, so the id a frontend displays and correlates on is
989/// this digest. The native id travels beside it, because that is the one the
990/// runtime's own resolve door needs.
991fn claude_request_id(native_id: &str) -> u64 {
992    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
993    for byte in native_id.as_bytes() {
994        hash ^= u64::from(*byte);
995        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
996    }
997    // Stay inside the range JSON numbers carry exactly.
998    hash & ((1_u64 << 53) - 1)
999}
1000
1001fn project_pi(key: &str, payload: &Value) -> Vec<Value> {
1002    match key {
1003        "agent_start" => vec![native_payload(key, payload)],
1004        "agent_end" => completion(payload.get("error").and_then(extract_text)),
1005        "message_update" => {
1006            // pi streams one assistant message as `text_start` → `text_delta`*
1007            // → `text_end` (the same for `thinking_*`). Only the `*_delta`
1008            // events carry NEW text; `text_end` repeats the whole block as
1009            // `content`, and projecting it too doubled every reply.
1010            let update = payload
1011                .get("assistantMessageEvent")
1012                .or_else(|| payload.get("event"))
1013                .unwrap_or(payload);
1014            let kind = update
1015                .get("type")
1016                .and_then(Value::as_str)
1017                .unwrap_or_default();
1018            match kind {
1019                "text_delta" => vec![
1020                    json!({"type":"text_delta", "text":update.get("delta").and_then(Value::as_str).unwrap_or_default(), "raw":payload}),
1021                ],
1022                "thinking_delta" => vec![
1023                    json!({"type":"reasoning", "text":update.get("delta").and_then(Value::as_str).unwrap_or_default(), "raw":payload}),
1024                ],
1025                _ => vec![native_payload(key, payload)],
1026            }
1027        }
1028        _ if key.starts_with("tool_execution_") => vec![project_tool(payload, payload)],
1029        _ => vec![native_payload(key, payload)],
1030    }
1031}
1032
1033fn project_opencode(key: &str, payload: &Value) -> Vec<Value> {
1034    if key == "session.idle" {
1035        return completion(None);
1036    }
1037    if key == "session.status" {
1038        let status = payload
1039            .pointer("/properties/status/type")
1040            .or_else(|| payload.pointer("/status/type"))
1041            .and_then(Value::as_str)
1042            .unwrap_or_default();
1043        if status == "busy" {
1044            return vec![native_payload(key, payload)];
1045        }
1046        if status == "idle" {
1047            return completion(None);
1048        }
1049    }
1050    if key == "message.part.updated" {
1051        let part = payload
1052            .pointer("/properties/part")
1053            .or_else(|| payload.get("part"))
1054            .unwrap_or(payload);
1055        if let Some(delta) = payload
1056            .pointer("/properties/delta")
1057            .or_else(|| payload.get("delta"))
1058            .and_then(Value::as_str)
1059        {
1060            let reasoning = part
1061                .get("type")
1062                .and_then(Value::as_str)
1063                .is_some_and(|kind| kind.contains("reasoning"));
1064            return vec![
1065                json!({"type":if reasoning { "reasoning" } else { "text_delta" }, "text":delta, "raw":payload}),
1066            ];
1067        }
1068        if part
1069            .get("type")
1070            .and_then(Value::as_str)
1071            .is_some_and(|kind| kind.contains("tool"))
1072        {
1073            return vec![project_tool(part, payload)];
1074        }
1075    }
1076    if key == "session.error" {
1077        return completion(Some(
1078            extract_text(payload).unwrap_or_else(|| "OpenCode session failed.".into()),
1079        ));
1080    }
1081    vec![native_payload(key, payload)]
1082}
1083
1084fn project_generic(key: &str, payload: &Value) -> Vec<Value> {
1085    match key {
1086        "turn_started" | "turn/started" | "agent_start" => {
1087            vec![native_payload(key, payload)]
1088        }
1089        "turn_completed" | "turn/completed" | "agent_end" => {
1090            completion(payload.get("error").and_then(extract_text))
1091        }
1092        "output_delta" | "content_delta" => vec![
1093            json!({"type":"text_delta", "text":extract_text(payload).unwrap_or_default(), "raw":payload}),
1094        ],
1095        "reasoning_delta" => vec![
1096            json!({"type":"reasoning", "text":extract_text(payload).unwrap_or_default(), "raw":payload}),
1097        ],
1098        "tool" => vec![project_tool(payload, payload)],
1099        _ => vec![native_payload(key, payload)],
1100    }
1101}
1102
1103fn completion(error: Option<String>) -> Vec<Value> {
1104    match error {
1105        Some(message) => vec![
1106            json!({"type":"turn_failed", "message":message}),
1107            json!({"type":"turn_completed"}),
1108        ],
1109        None => vec![
1110            json!({"type":"turn_succeeded"}),
1111            json!({"type":"turn_completed"}),
1112        ],
1113    }
1114}
1115
1116fn project_tool(source: &Value, raw: &Value) -> Value {
1117    let status = source
1118        .get("status")
1119        .or_else(|| source.get("state"))
1120        .or_else(|| source.get("sessionUpdate"))
1121        .and_then(Value::as_str)
1122        .unwrap_or_default()
1123        .to_ascii_lowercase();
1124    let completed = status.contains("complete")
1125        || status.contains("result")
1126        || status.contains("success")
1127        || status.contains("error")
1128        || status.contains("fail");
1129    let arguments = source
1130        .get("arguments")
1131        .or_else(|| source.get("input"))
1132        .or_else(|| source.get("rawInput"))
1133        .cloned()
1134        .unwrap_or(Value::Null);
1135    json!({
1136        "type": if completed { "tool_call_completed" } else { "tool_call_started" },
1137        "id": source.get("toolCallId").or_else(|| source.get("tool_call_id")).or_else(|| source.get("callId")).or_else(|| source.get("id")).cloned().unwrap_or(Value::Null),
1138        "name": source.get("title").or_else(|| source.get("name")).or_else(|| source.get("tool")).or_else(|| source.get("toolName")).cloned().unwrap_or(Value::Null),
1139        "arguments": if arguments.is_string() { arguments } else { Value::String(arguments.to_string()) },
1140        "output": extract_text(source.get("result").or_else(|| source.get("output")).or_else(|| source.get("content")).unwrap_or(&Value::Null)).unwrap_or_default(),
1141        "is_error": status.contains("error") || status.contains("fail") || status.contains("denied"),
1142        "raw": raw,
1143    })
1144}
1145
1146fn native_payload(kind: &str, payload: &Value) -> Value {
1147    json!({"type":"native_event", "kind":kind, "raw":payload})
1148}
1149
1150fn extract_text(value: &Value) -> Option<String> {
1151    match value {
1152        Value::String(text) => Some(text.clone()),
1153        Value::Array(values) => {
1154            let text = values
1155                .iter()
1156                .filter_map(extract_text)
1157                .collect::<Vec<_>>()
1158                .join("\n");
1159            (!text.is_empty()).then_some(text)
1160        }
1161        Value::Object(object) => {
1162            for key in ["text", "delta", "content", "message", "result", "error"] {
1163                if let Some(text) = object.get(key).and_then(Value::as_str) {
1164                    return Some(text.to_string());
1165                }
1166            }
1167            for key in [
1168                "delta",
1169                "content",
1170                "message",
1171                "error",
1172                "data",
1173                "part",
1174                "params",
1175                "properties",
1176                "update",
1177                "event",
1178            ] {
1179                if let Some(text) = object.get(key).and_then(extract_text) {
1180                    return Some(text);
1181                }
1182            }
1183            None
1184        }
1185        _ => None,
1186    }
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    #[test]
1192    fn pi_message_update_projects_only_the_deltas() {
1193        use serde_json::json;
1194        let delta = json!({"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"ack: hi"}});
1195        let end = json!({"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"ack: hi"}});
1196        let start = json!({"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":0}});
1197        let projected = super::project_pi("message_update", &delta);
1198        assert_eq!(projected[0]["type"], "text_delta");
1199        assert_eq!(projected[0]["text"], "ack: hi");
1200        for repeat in [&end, &start] {
1201            let projected = super::project_pi("message_update", repeat);
1202            assert_eq!(
1203                projected[0]["type"], "native_event",
1204                "{repeat} carries no new text"
1205            );
1206        }
1207        let thinking = json!({"type":"message_update","assistantMessageEvent":{"type":"thinking_delta","contentIndex":0,"delta":"hm"}});
1208        let projected = super::project_pi("message_update", &thinking);
1209        assert_eq!(projected[0]["type"], "reasoning");
1210        assert_eq!(projected[0]["text"], "hm");
1211    }
1212
1213    use super::*;
1214    use crate::{HarnessId, RuntimeEndpoint};
1215
1216    struct ControlledRuntime {
1217        handle: RuntimeHandle,
1218        events: mpsc::UnboundedReceiver<HarnessEvent>,
1219        close_failures: usize,
1220    }
1221
1222    #[async_trait]
1223    impl RuntimeConnection for ControlledRuntime {
1224        fn handle(&self) -> &RuntimeHandle {
1225            &self.handle
1226        }
1227
1228        async fn send_input(&mut self, _input: RuntimeInput) -> Result<Option<String>> {
1229            Ok(Some("turn-1".into()))
1230        }
1231
1232        async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1233            Ok(self.events.recv().await)
1234        }
1235
1236        async fn interrupt(&mut self) -> Result<()> {
1237            Ok(())
1238        }
1239
1240        async fn respond(&mut self, _request_id: Value, _response: Value) -> Result<()> {
1241            Ok(())
1242        }
1243
1244        async fn close(&mut self) -> Result<()> {
1245            if self.close_failures > 0 {
1246                self.close_failures -= 1;
1247                return Err(Error::Other("cleanup temporarily unavailable".into()));
1248            }
1249            Ok(())
1250        }
1251    }
1252
1253    fn controlled_runtime() -> (
1254        Box<dyn RuntimeConnection>,
1255        mpsc::UnboundedSender<HarnessEvent>,
1256    ) {
1257        controlled_runtime_with_close_failures(0)
1258    }
1259
1260    fn controlled_runtime_with_close_failures(
1261        close_failures: usize,
1262    ) -> (
1263        Box<dyn RuntimeConnection>,
1264        mpsc::UnboundedSender<HarnessEvent>,
1265    ) {
1266        let (events, event_rx) = mpsc::unbounded_channel();
1267        (
1268            Box::new(ControlledRuntime {
1269                handle: RuntimeHandle {
1270                    harness: HarnessId::from(HarnessId::PI),
1271                    runtime_id: "shared-runtime".into(),
1272                    endpoint: RuntimeEndpoint::LocalProcess {
1273                        pid: None,
1274                        command: vec!["controlled-runtime".into()],
1275                        protocol: "test".into(),
1276                    },
1277                },
1278                events: event_rx,
1279                close_failures,
1280            }),
1281            events,
1282        )
1283    }
1284
1285    #[tokio::test]
1286    async fn failed_runtime_close_does_not_stop_the_host_or_discard_its_owner() {
1287        let (runtime, _events) = controlled_runtime_with_close_failures(1);
1288        let (host, mut owner) = HostedHarnessRuntime::spawn(runtime, capabilities());
1289        assert!(owner.close().await.is_err());
1290        assert!(!owner.closed);
1291        assert!(!host.closed.load(Ordering::SeqCst));
1292        owner
1293            .send_input(RuntimeInput {
1294                text: "still usable".into(),
1295                image_urls: Vec::new(),
1296            })
1297            .await
1298            .unwrap();
1299        owner.close().await.unwrap();
1300        assert!(owner.closed);
1301        assert!(host.closed.load(Ordering::SeqCst));
1302    }
1303
1304    fn capabilities() -> RuntimeCapabilities {
1305        RuntimeCapabilities {
1306            start_session: true,
1307            resume_session: true,
1308            attach_existing_process: false,
1309            send_input: true,
1310            stream_events: true,
1311            interrupt: true,
1312            steer: false,
1313            respond_to_requests: false,
1314        }
1315    }
1316
1317    #[tokio::test]
1318    async fn native_eof_closes_the_raw_owner_connection() {
1319        let (runtime, events) = controlled_runtime();
1320        let (_host, mut connection) = HostedHarnessRuntime::spawn(runtime, capabilities());
1321        drop(events);
1322
1323        let event =
1324            tokio::time::timeout(std::time::Duration::from_secs(1), connection.next_event())
1325                .await
1326                .expect("raw owner should not hang after native EOF")
1327                .unwrap()
1328                .expect("EOF is projected as an explicit terminal event");
1329        assert_eq!(event.kind, "transport_closed");
1330        assert_eq!(event.payload["terminal"], true);
1331    }
1332
1333    #[tokio::test]
1334    async fn adapters_without_an_operation_route_preserve_the_requested_id() {
1335        let (runtime, _events) = controlled_runtime();
1336        let (host, _owner) = HostedHarnessRuntime::spawn(runtime, capabilities());
1337        let operation_id = "prompt:not-advertised".to_string();
1338        let error = FrontendRuntime::invoke(
1339            host.as_ref(),
1340            crate::FrontendOperationInvocation::Prompt {
1341                operation_id: operation_id.clone(),
1342                arguments: String::new(),
1343            },
1344        )
1345        .await
1346        .unwrap_err();
1347
1348        assert!(
1349            matches!(error, FrontendRuntimeError::UnsupportedOperation(id) if id == operation_id)
1350        );
1351    }
1352
1353    #[tokio::test]
1354    async fn interrupt_stays_busy_until_the_native_terminal_event() {
1355        let (runtime, events) = controlled_runtime();
1356        let (host, mut owner) = HostedHarnessRuntime::spawn(runtime, capabilities());
1357        let mut terminal = FrontendRuntime::attach(host.as_ref(), 100).await.unwrap();
1358
1359        assert_eq!(
1360            FrontendRuntime::submit(host.as_ref(), "hello".into())
1361                .await
1362                .unwrap(),
1363            "turn-1"
1364        );
1365        assert_eq!(terminal.next_event().await.unwrap().kind, "user_message");
1366        assert_eq!(terminal.next_event().await.unwrap().kind, "turn_started");
1367        assert!(FrontendRuntime::interrupt(host.as_ref()).await.unwrap());
1368        assert_eq!(
1369            FrontendRuntime::describe(host.as_ref())
1370                .await
1371                .unwrap()
1372                .turn_state,
1373            FrontendTurnState::Busy
1374        );
1375        assert!(
1376            tokio::time::timeout(std::time::Duration::from_millis(20), terminal.next_event())
1377                .await
1378                .is_err(),
1379            "interrupt acceptance must not manufacture turn completion"
1380        );
1381
1382        events
1383            .send(HarnessEvent {
1384                sequence: None,
1385                kind: "agent_end".into(),
1386                payload: json!({}),
1387            })
1388            .unwrap();
1389        assert_eq!(terminal.next_event().await.unwrap().kind, "turn_succeeded");
1390        assert_eq!(terminal.next_event().await.unwrap().kind, "turn_completed");
1391        assert_eq!(
1392            FrontendRuntime::describe(host.as_ref())
1393                .await
1394                .unwrap()
1395                .turn_state,
1396            FrontendTurnState::Idle
1397        );
1398        owner.close().await.unwrap();
1399    }
1400
1401    #[test]
1402    fn native_start_events_do_not_duplicate_the_hosted_turn_boundary() {
1403        let event = HarnessEvent {
1404            sequence: None,
1405            kind: "turn/started".into(),
1406            payload: json!({"method":"turn/started"}),
1407        };
1408        let projected = project_native_event(HarnessId::CODEX, &event);
1409        assert_eq!(projected.len(), 1);
1410        assert_eq!(projected[0]["type"], "native_event");
1411    }
1412}