//! Agent runtimes — pluggable backends (Claude Code, Gemini, OpenAI) for running agents. mod claude_code; mod gemini; mod openai; pub use claude_code::ClaudeCodeRuntime; pub use gemini::GeminiRuntime; pub use openai::OpenAiRuntime; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; use crate::agent_log::AgentLogWriter; use crate::http::context::AppContext; use super::{AgentEvent, TokenUsage}; /// Context passed to a runtime when launching an agent session. pub struct RuntimeContext { pub story_id: String, pub agent_name: String, pub command: String, pub args: Vec, pub prompt: String, pub cwd: String, pub inactivity_timeout_secs: u64, /// Shared application context, used by API-based runtimes (Gemini, OpenAI) /// to invoke MCP tool dispatch directly without an HTTP round-trip. /// `None` in tests or when the pool is created before `AppContext` exists. pub app_ctx: Option>, /// When set, resume a previous Claude Code session instead of starting fresh. /// /// The CLI is invoked as `claude --resume [-p ]` rather /// than `claude -p `. The agent re-enters the previous /// conversation and receives the `prompt` (if non-empty) as a new message. pub session_id_to_resume: Option, /// Full rendered prompt for a fresh session, kept as fallback if resume fails. /// /// When `session_id_to_resume` is `Some`, `prompt` contains only the /// resume context (e.g. gate failure output). If the CLI rejects the /// resume (session expired, file missing, version mismatch), the runtime /// retries with this full prompt and no `--resume` flag. pub fresh_prompt: Option, /// Project root path — passed to the PTY runner so it can eagerly record /// the session_id as soon as the `"system"` event is seen (bug 967). /// Eager recording ensures the session survives a watchdog kill that aborts /// the tokio task before `run_agent_spawn`'s `record_session()` call runs. pub project_root: std::path::PathBuf, /// Agent model — forms part of the session store key used for eager /// recording (bug 967). `None` disables eager recording. pub model: Option, } /// Result returned by a runtime after the agent session completes. pub struct RuntimeResult { pub session_id: Option, pub token_usage: Option, /// `true` when the process exited with a failure AND no session was established. /// /// This indicates the Claude Code CLI crashed (e.g. SIGABRT from an assertion /// failure) before it could emit its first "system" event — the classic /// "signal=Aborted, Session: None" case (bug 882). The completion handler /// uses this flag to skip acceptance gates and respawn without consuming a /// retry slot. Always `false` for API-based runtimes (Gemini, OpenAI). pub aborted_signal: bool, /// `true` when the Claude Code CLI received a rate-limit hard block event /// before exiting (bug 1053). /// /// The completion handler uses this to distinguish a rate-limit-forced exit /// from a genuine no-progress exit, so commit-recovery counters are not /// incremented. Always `false` for API-based runtimes (Gemini, OpenAI). pub rate_limit_exit: bool, /// When the API rate limit is scheduled to reset. /// /// Populated from the `reset_at` field of the `rate_limit_event` JSON when /// `rate_limit_exit` is `true`. The completion handler honours this window /// before re-attempting the agent spawn. `None` for API-based runtimes. pub rate_limit_reset_at: Option>, } /// Runtime status reported by the backend. #[derive(Debug, Clone, PartialEq)] #[allow(dead_code)] pub enum RuntimeStatus { Idle, Running, Completed, Failed, } /// Abstraction over different agent execution backends. /// /// Implementations: /// - [`ClaudeCodeRuntime`]: spawns the `claude` CLI via a PTY (default, `runtime = "claude-code"`) /// /// Future implementations could include OpenAI and Gemini API runtimes. #[allow(dead_code)] pub trait AgentRuntime: Send + Sync { /// Start the agent and drive it to completion, streaming events through /// the provided broadcast sender and event log. /// /// Returns when the agent session finishes (success or error). async fn start( &self, ctx: RuntimeContext, tx: broadcast::Sender, event_log: Arc>>, log_writer: Option>>, ) -> Result; /// Stop the running agent. fn stop(&self); /// Get the current runtime status. fn get_status(&self) -> RuntimeStatus; /// Return any events buffered outside the broadcast channel. /// /// PTY-based runtimes stream directly to the broadcast channel; this /// returns empty by default. API-based runtimes may buffer events here. fn stream_events(&self) -> Vec { vec![] } } #[cfg(test)] mod tests { use super::*; use crate::http::context::AppContext; fn test_app_ctx() -> Arc { let tmp = tempfile::tempdir().unwrap(); Arc::new(AppContext::new_test(tmp.path().to_path_buf())) } #[test] fn runtime_context_fields() { let ctx = RuntimeContext { story_id: "42_story_foo".to_string(), agent_name: "coder-1".to_string(), command: "claude".to_string(), args: vec!["--model".to_string(), "sonnet".to_string()], prompt: "Do the thing".to_string(), cwd: "/tmp/wt".to_string(), inactivity_timeout_secs: 300, app_ctx: Some(test_app_ctx()), session_id_to_resume: None, fresh_prompt: None, project_root: std::path::PathBuf::from("/tmp/project"), model: Some(crate::agents::AgentModel::Sonnet), }; assert_eq!(ctx.story_id, "42_story_foo"); assert_eq!(ctx.agent_name, "coder-1"); assert_eq!(ctx.command, "claude"); assert_eq!(ctx.args.len(), 2); assert_eq!(ctx.prompt, "Do the thing"); assert_eq!(ctx.cwd, "/tmp/wt"); assert_eq!(ctx.inactivity_timeout_secs, 300); } #[test] fn runtime_result_fields() { let result = RuntimeResult { session_id: Some("sess-123".to_string()), token_usage: Some(TokenUsage { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_cost_usd: 0.01, }), aborted_signal: false, rate_limit_exit: false, rate_limit_reset_at: None, }; assert_eq!(result.session_id, Some("sess-123".to_string())); assert!(result.token_usage.is_some()); let usage = result.token_usage.unwrap(); assert_eq!(usage.input_tokens, 100); assert_eq!(usage.output_tokens, 50); assert_eq!(usage.total_cost_usd, 0.01); } #[test] fn runtime_result_no_usage() { let result = RuntimeResult { session_id: None, token_usage: None, aborted_signal: false, rate_limit_exit: false, rate_limit_reset_at: None, }; assert!(result.session_id.is_none()); assert!(result.token_usage.is_none()); } #[test] fn runtime_status_variants() { assert_eq!(RuntimeStatus::Idle, RuntimeStatus::Idle); assert_ne!(RuntimeStatus::Running, RuntimeStatus::Completed); assert_ne!(RuntimeStatus::Failed, RuntimeStatus::Idle); } #[test] fn claude_code_runtime_get_status_returns_idle() { use crate::io::watcher::WatcherEvent; let (watcher_tx, _) = broadcast::channel::(16); let runtime = ClaudeCodeRuntime::new(watcher_tx); assert_eq!(runtime.get_status(), RuntimeStatus::Idle); } #[test] fn claude_code_runtime_stream_events_empty() { use crate::io::watcher::WatcherEvent; let (watcher_tx, _) = broadcast::channel::(16); let runtime = ClaudeCodeRuntime::new(watcher_tx); assert!(runtime.stream_events().is_empty()); } }