Skip to main content

harness/
runner.rs

1use async_trait::async_trait;
2use tokio::sync::mpsc;
3
4use super::event::{HarnessInternalEvent, NativeHarnessError, NativeTurnInput};
5use super::tools::{MockToolRuntime, ToolInvocation, ToolRuntime};
6
7#[async_trait]
8pub trait NativeHarness: Send + Sync {
9    async fn run_turn(
10        &self,
11        input: NativeTurnInput,
12    ) -> Result<mpsc::Receiver<Result<HarnessInternalEvent, NativeHarnessError>>, NativeHarnessError>;
13}
14
15#[derive(Debug, Clone)]
16pub struct FakeNativeHarness {
17    events: Vec<HarnessInternalEvent>,
18}
19
20impl FakeNativeHarness {
21    pub fn new(events: Vec<HarnessInternalEvent>) -> Self {
22        Self { events }
23    }
24}
25
26#[async_trait]
27impl NativeHarness for FakeNativeHarness {
28    async fn run_turn(
29        &self,
30        _input: NativeTurnInput,
31    ) -> Result<mpsc::Receiver<Result<HarnessInternalEvent, NativeHarnessError>>, NativeHarnessError>
32    {
33        let (tx, rx) = mpsc::channel(self.events.len().max(1));
34        for ev in self.events.clone() {
35            tx.send(Ok(ev))
36                .await
37                .map_err(|_| NativeHarnessError::ChannelClosed)?;
38        }
39        drop(tx);
40        Ok(rx)
41    }
42}
43
44pub struct ToolCapableFakeHarness<R = MockToolRuntime> {
45    runtime: R,
46}
47
48impl ToolCapableFakeHarness<MockToolRuntime> {
49    pub fn mock() -> Self {
50        Self {
51            runtime: MockToolRuntime::new().with_file("README.md", "mock readme"),
52        }
53    }
54}
55
56impl<R> ToolCapableFakeHarness<R> {
57    pub fn new(runtime: R) -> Self {
58        Self { runtime }
59    }
60}
61
62#[async_trait]
63impl<R> NativeHarness for ToolCapableFakeHarness<R>
64where
65    R: ToolRuntime + Send + Sync,
66{
67    async fn run_turn(
68        &self,
69        input: NativeTurnInput,
70    ) -> Result<mpsc::Receiver<Result<HarnessInternalEvent, NativeHarnessError>>, NativeHarnessError>
71    {
72        let (tx, rx) = mpsc::channel(8);
73        let tool_input = tool_invocation_from_prompt(&input.prompt_text);
74        let tool_name = tool_input.name.clone();
75        let tool_id = tool_input.id.clone();
76        let raw_input = tool_input.input.clone();
77        tx.send(Ok(HarnessInternalEvent::AssistantTextChunk {
78            msg_id: "msg_native_1".into(),
79            delta: format!("native harness executing tool: {tool_name}"),
80        }))
81        .await
82        .map_err(|_| NativeHarnessError::ChannelClosed)?;
83        tx.send(Ok(HarnessInternalEvent::ToolCall {
84            id: tool_id.clone(),
85            name: tool_name,
86            input: raw_input,
87        }))
88        .await
89        .map_err(|_| NativeHarnessError::ChannelClosed)?;
90
91        let outcome = self.runtime.invoke(tool_input).await.map_err(|e| {
92            NativeHarnessError::Failed(format!("tool runtime invocation failed: {e}"))
93        })?;
94        tx.send(Ok(HarnessInternalEvent::ToolResult {
95            id: tool_id,
96            output: outcome.output.map_err(|failure| failure.to_string()),
97        }))
98        .await
99        .map_err(|_| NativeHarnessError::ChannelClosed)?;
100        tx.send(Ok(HarnessInternalEvent::TurnEnd {
101            stop_reason: "end_turn".into(),
102            usage: None,
103            // ScriptedNativeRunner is a single-shot stub kept around for
104            // smoke tests; multi-turn replay isn't a use case here, so
105            // we emit an empty history snapshot.
106            final_messages: vec![],
107        }))
108        .await
109        .map_err(|_| NativeHarnessError::ChannelClosed)?;
110        drop(tx);
111        Ok(rx)
112    }
113}
114
115fn tool_invocation_from_prompt(prompt: &str) -> ToolInvocation {
116    let trimmed = prompt.trim();
117    if let Some(path) = trimmed.strip_prefix("read ") {
118        ToolInvocation {
119            id: "tc_read_1".into(),
120            name: "read".into(),
121            input: serde_json::json!({"path": path.trim()}),
122        }
123    } else if let Some(rest) = trimmed.strip_prefix("write ") {
124        let (path, content) = rest.split_once(' ').unwrap_or((rest, ""));
125        ToolInvocation {
126            id: "tc_write_1".into(),
127            name: "write".into(),
128            input: serde_json::json!({"path": path.trim(), "content": content}),
129        }
130    } else {
131        ToolInvocation {
132            id: "tc_bash_1".into(),
133            name: "bash".into(),
134            input: serde_json::json!({"command": trimmed}),
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::HarnessInternalEvent;
143
144    #[tokio::test]
145    async fn tool_capable_fake_harness_emits_tool_lifecycle() {
146        let harness = ToolCapableFakeHarness::mock();
147        let mut rx = harness
148            .run_turn(NativeTurnInput {
149                prompt_text: "read README.md".into(),
150                system_prompt: None,
151                attachments: vec![],
152                cancel_token: None,
153                prior_messages: vec![],
154                context_path: None,
155            })
156            .await
157            .unwrap();
158
159        assert!(matches!(
160            rx.recv().await.unwrap().unwrap(),
161            HarnessInternalEvent::AssistantTextChunk { .. }
162        ));
163        assert!(matches!(
164            rx.recv().await.unwrap().unwrap(),
165            HarnessInternalEvent::ToolCall { ref name, .. } if name == "read"
166        ));
167        assert!(matches!(
168            rx.recv().await.unwrap().unwrap(),
169            HarnessInternalEvent::ToolResult { .. }
170        ));
171        assert!(matches!(
172            rx.recv().await.unwrap().unwrap(),
173            HarnessInternalEvent::TurnEnd { .. }
174        ));
175        assert!(rx.recv().await.is_none());
176    }
177}