pub mod claude;
mod claude_mapping;
pub mod codex;
mod codex_mapping;
mod common;
#[cfg(test)]
mod conformance_tests;
mod lf_tag;
pub mod opencode;
mod opencode_mapping;
pub mod opencode_runtime;
use anyhow::Result;
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::chat::types::ConversationEvent;
use crate::engine::agent::AgentConfig;
#[derive(Debug, Clone)]
pub struct RawProviderEvent {
pub stream: &'static str,
pub line: String,
}
#[derive(Debug, thiserror::Error)]
pub enum HarnessError {
#[error("turn already in progress")]
TurnAlreadyInProgress,
}
pub fn is_turn_in_progress(err: &anyhow::Error) -> bool {
matches!(
err.downcast_ref::<HarnessError>(),
Some(HarnessError::TurnAlreadyInProgress)
)
}
pub fn is_terminal_harness_error(code: &str) -> bool {
matches!(code, "codex_disconnected" | "opencode_disconnected")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capabilities {
pub supports_steer: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalPolicy {
AutoApprove,
}
#[async_trait]
pub trait Harness: Send + Sync {
async fn start(&mut self, config: &AgentConfig) -> Result<()>;
async fn send_input(&mut self, content: &str) -> Result<()>;
async fn interrupt(&mut self) -> Result<()>;
async fn stop(&mut self) -> Result<()>;
fn capabilities(&self) -> Capabilities;
fn provider_session_id(&self) -> Option<String>;
fn set_raw_provider_sender(
&mut self,
_raw_provider: Option<mpsc::UnboundedSender<RawProviderEvent>>,
) {
}
fn set_provider_session_id(&mut self, _provider_session_id: Option<String>) {}
}
pub type CreateHarnessFn =
fn(&str, ApprovalPolicy, mpsc::UnboundedSender<ConversationEvent>) -> Result<Box<dyn Harness>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HarnessKind {
Codex,
Claude,
OpenCode,
}
impl HarnessKind {
pub fn parse(name: &str) -> Option<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"codex" => Some(Self::Codex),
"claude" => Some(Self::Claude),
"opencode" => Some(Self::OpenCode),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Codex => "codex",
Self::Claude => "claude",
Self::OpenCode => "opencode",
}
}
fn create(
self,
approval: ApprovalPolicy,
event_tx: mpsc::UnboundedSender<ConversationEvent>,
) -> Box<dyn Harness> {
match self {
Self::Codex => Box::new(codex::CodexHarness::new(event_tx, approval)),
Self::Claude => Box::new(claude::ClaudeHarness::new(event_tx)),
Self::OpenCode => Box::new(opencode::OpenCodeHarness::new(event_tx, approval)),
}
}
}
pub fn canonical_harness(name: &str) -> Option<&'static str> {
HarnessKind::parse(name).map(HarnessKind::as_str)
}
pub fn default_create_harness(
name: &str,
approval: ApprovalPolicy,
event_tx: mpsc::UnboundedSender<ConversationEvent>,
) -> Result<Box<dyn Harness>> {
if let Some(kind) = HarnessKind::parse(name) {
return Ok(kind.create(approval, event_tx));
}
anyhow::bail!(
"unsupported session harness: {}",
name.trim().to_lowercase()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_harness_is_case_insensitive_and_trimmed() {
assert_eq!(canonical_harness(" claUDe "), Some("claude"));
assert_eq!(canonical_harness(" CODEX"), Some("codex"));
assert_eq!(canonical_harness("OpenCode"), Some("opencode"));
assert_eq!(canonical_harness("lfharness"), None);
}
#[test]
fn default_create_harness_rejects_unknown() {
let (tx, _rx) = mpsc::unbounded_channel();
match default_create_harness("lfharness", ApprovalPolicy::AutoApprove, tx) {
Ok(_) => panic!("should reject unknown harness"),
Err(err) => assert!(err.to_string().contains("unsupported session harness")),
}
}
#[test]
fn terminal_harness_error_recognizes_disconnects_only() {
assert!(is_terminal_harness_error("opencode_disconnected"));
assert!(is_terminal_harness_error("codex_disconnected"));
assert!(!is_terminal_harness_error("opencode_error"));
assert!(!is_terminal_harness_error("claude_harness_crashed"));
}
#[tokio::test]
async fn capabilities_steer_is_codex_only() {
let (tx, _rx) = mpsc::unbounded_channel();
for (name, steer) in [("codex", true), ("claude", false), ("opencode", false)] {
let harness = default_create_harness(name, ApprovalPolicy::AutoApprove, tx.clone())
.expect("known harness");
let caps = harness.capabilities();
assert_eq!(caps.supports_steer, steer, "steer for {name}");
}
}
}