OpenAiProvider gains a custom id and a chat-only mode; `OpenAiProvider::opencode` builds an OpenCode Zen provider (id `opencode`, defaults to Zen's gateway, always chat-completions so even gpt-*/o* model ids route correctly). Registered from a `providers.opencode` config block / OPENCODE_API_KEY env, so it coexists with a real `openai` provider. Models are referenced as `opencode/<model>`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1369 lines
49 KiB
Rust
1369 lines
49 KiB
Rust
//! Composition root: builds the pieces `harness-tui` (and later `harness-server`) need —
|
|
//! config, store, event bus, permission service, tool registry, provider registry — and
|
|
//! exposes a small headless API (`run_prompt`) used by the `harness run -p` debug command.
|
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::collections::HashMap;
|
|
use std::hash::Hasher;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use async_trait::async_trait;
|
|
use harness_core::agent::{AgentDef, AgentRegistry};
|
|
use harness_core::config::{self, Config, ConfigError};
|
|
use harness_core::engine::jobs::{JobBoard, JobState, LaunchSpec};
|
|
use harness_core::engine::{run_session, RunConfig, StepContext};
|
|
use harness_core::event::{AppEvent, EventBus, RunOutcome};
|
|
use harness_core::permission::{PermissionReply, PermissionService, Rule, Ruleset};
|
|
use harness_core::store::{Store, StoreError};
|
|
use harness_core::tool::{
|
|
ContextReporter, SpawnError, SpawnOutcome, SpawnRequest, SubagentSpawner, ToolRegistry,
|
|
};
|
|
use harness_core::types::{
|
|
Message, MessageId, ModelRef, Part, PartBody, PartId, Session, SessionId,
|
|
};
|
|
use harness_providers::{AnthropicProvider, ModelCatalog, OpenAiProvider, ProviderRegistry};
|
|
use tokio::task::JoinHandle;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
/// Fallback prompt for an agent whose markdown body is empty or that isn't in the registry.
|
|
pub const DEFAULT_AGENT_PROMPT: &str =
|
|
"You are a helpful coding assistant with access to tools for reading, editing, running \
|
|
commands, and searching code. Use them as needed to satisfy the user's request, then \
|
|
reply with a concise final answer.";
|
|
|
|
const DEFAULT_MAX_STEPS: u32 = 50;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AppError {
|
|
#[error(transparent)]
|
|
Config(#[from] ConfigError),
|
|
#[error(transparent)]
|
|
Store(#[from] StoreError),
|
|
#[error("model ref must be \"provider/model\", got {0:?}")]
|
|
InvalidModelRef(String),
|
|
#[error("no provider registered for {0:?} (missing API key?)")]
|
|
UnknownProvider(String),
|
|
#[error("session {0} already has an active run")]
|
|
SessionRunning(SessionId),
|
|
}
|
|
|
|
fn now_ms() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as i64
|
|
}
|
|
|
|
fn db_path(cwd: &Path) -> PathBuf {
|
|
let base = dirs::data_dir()
|
|
.unwrap_or_else(std::env::temp_dir)
|
|
.join("ai-harness")
|
|
.join("db");
|
|
let cwd_str = cwd.to_string_lossy();
|
|
let sanitized: String = cwd_str
|
|
.chars()
|
|
.map(|c| if c.is_alphanumeric() { c } else { '_' })
|
|
.collect();
|
|
let mut hasher = DefaultHasher::new();
|
|
hasher.write(cwd_str.as_bytes());
|
|
let hash = hasher.finish();
|
|
let hash_hex = format!("{:016x}", hash);
|
|
let slug = format!("{}_{}", sanitized, &hash_hex[..8]);
|
|
base.join(format!("{}.sqlite", slug))
|
|
}
|
|
|
|
/// Non-blocking, multi-turn engine API used by the TUI.
|
|
#[derive(Clone)]
|
|
pub struct EngineHandle {
|
|
inner: Arc<EngineInner>,
|
|
}
|
|
|
|
struct EngineInner {
|
|
config: Config,
|
|
store: Store,
|
|
bus: EventBus,
|
|
permissions: Arc<PermissionService>,
|
|
tools: ToolRegistry,
|
|
providers: ProviderRegistry,
|
|
catalog: ModelCatalog,
|
|
agents: AgentRegistry,
|
|
cwd: PathBuf,
|
|
data_dir: PathBuf,
|
|
runs: Mutex<HashMap<SessionId, RunHandle>>,
|
|
/// One job board per session (scoped to that session as a parent). Lazily created and
|
|
/// shared between a session's run loop (board injection) and the spawner (registration).
|
|
boards: Mutex<HashMap<SessionId, Arc<JobBoard>>>,
|
|
/// Self-reference so the spawner can hand an `Arc<EngineInner>` to child contexts (for
|
|
/// nested delegation) and to detached background tasks. Set once in `new`.
|
|
me: OnceLock<Weak<EngineInner>>,
|
|
}
|
|
|
|
struct RunHandle {
|
|
cancel: CancellationToken,
|
|
}
|
|
|
|
impl EngineHandle {
|
|
/// Persistent SQLite-backed store. Used by the TUI and the default headless `App`.
|
|
pub fn init(cwd: PathBuf) -> Result<Self, AppError> {
|
|
let path = db_path(&cwd);
|
|
let store = Store::open(&path)?;
|
|
let handle = Self::new(cwd, store)?;
|
|
handle.spawn_catalog_refresh();
|
|
Ok(handle)
|
|
}
|
|
|
|
/// In-memory store — useful for tests and ephemeral sessions.
|
|
pub fn init_in_memory(cwd: PathBuf) -> Result<Self, AppError> {
|
|
let store = Store::open_in_memory()?;
|
|
Self::new(cwd, store)
|
|
}
|
|
|
|
fn new(cwd: PathBuf, store: Store) -> Result<Self, AppError> {
|
|
let config = config::load(&cwd)?;
|
|
|
|
let mut providers = ProviderRegistry::new();
|
|
if let Some(key) = config
|
|
.providers
|
|
.get("anthropic")
|
|
.and_then(|p| p.api_key.clone())
|
|
{
|
|
providers.register(Arc::new(AnthropicProvider::new(key)));
|
|
}
|
|
if let Some(key) = config
|
|
.providers
|
|
.get("openai")
|
|
.and_then(|p| p.api_key.clone())
|
|
{
|
|
let provider = match config
|
|
.providers
|
|
.get("openai")
|
|
.and_then(|p| p.base_url.clone())
|
|
{
|
|
Some(base_url) => OpenAiProvider::with_base_url(key, base_url),
|
|
None => OpenAiProvider::new(key),
|
|
};
|
|
providers.register(Arc::new(provider));
|
|
}
|
|
// OpenCode Zen: OpenAI-compatible chat gateway under its own `opencode` provider id,
|
|
// so it coexists with a real `openai` provider. `base_url` defaults to Zen's endpoint.
|
|
if let Some(key) = config
|
|
.providers
|
|
.get("opencode")
|
|
.and_then(|p| p.api_key.clone())
|
|
{
|
|
let base_url = config
|
|
.providers
|
|
.get("opencode")
|
|
.and_then(|p| p.base_url.clone());
|
|
providers.register(Arc::new(OpenAiProvider::opencode(key, base_url)));
|
|
}
|
|
|
|
Self::build(cwd, store, config, providers)
|
|
}
|
|
|
|
/// Shared construction over an explicit provider set — the seam tests use to inject a mock.
|
|
fn build(
|
|
cwd: PathBuf,
|
|
store: Store,
|
|
config: Config,
|
|
providers: ProviderRegistry,
|
|
) -> Result<Self, AppError> {
|
|
let bus = EventBus::new();
|
|
let permissions = Arc::new(PermissionService::new(bus.clone()));
|
|
|
|
let mut tools = ToolRegistry::new();
|
|
harness_tools::register_builtins(&mut tools);
|
|
harness_tools::register_task_tool(&mut tools);
|
|
|
|
// Layered agent registry: bundled markdown → global dir → project dir → config patches.
|
|
let global_agent_dir = dirs::config_dir().map(|d| d.join("ai-harness").join("agent"));
|
|
let project_agent_dir = cwd.join(".harness").join("agent");
|
|
let agents = AgentRegistry::load(
|
|
&config.agents,
|
|
global_agent_dir.as_deref(),
|
|
Some(project_agent_dir.as_path()),
|
|
);
|
|
|
|
let data_dir = dirs::data_dir()
|
|
.unwrap_or_else(std::env::temp_dir)
|
|
.join("ai-harness")
|
|
.join("tool-output");
|
|
|
|
// No network at construction: use the cached copy if fresh, else the baked snapshot.
|
|
// `init` warms the cache in the background for the next launch.
|
|
let catalog = ModelCatalog::load_cached_or_baked(&ModelCatalog::default_cache_path());
|
|
|
|
let inner = Arc::new(EngineInner {
|
|
config,
|
|
store,
|
|
bus,
|
|
permissions,
|
|
tools,
|
|
providers,
|
|
catalog,
|
|
agents,
|
|
cwd,
|
|
data_dir,
|
|
runs: Mutex::new(HashMap::new()),
|
|
boards: Mutex::new(HashMap::new()),
|
|
me: OnceLock::new(),
|
|
});
|
|
let _ = inner.me.set(Arc::downgrade(&inner));
|
|
Ok(Self { inner })
|
|
}
|
|
|
|
/// Fire-and-forget refresh of the models.dev cache so the next launch has current pricing.
|
|
fn spawn_catalog_refresh(&self) {
|
|
tokio::spawn(async {
|
|
if let Err(e) = ModelCatalog::refresh_default_cache().await {
|
|
tracing::debug!(error = %e, "models.dev refresh failed; using cached/baked metadata");
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn bus(&self) -> EventBus {
|
|
self.inner.bus.clone()
|
|
}
|
|
|
|
pub fn permissions(&self) -> Arc<PermissionService> {
|
|
self.inner.permissions.clone()
|
|
}
|
|
|
|
pub fn config(&self) -> Config {
|
|
self.inner.config.clone()
|
|
}
|
|
|
|
pub async fn new_session(&self, agent: &str, model_ref: &str) -> Result<SessionId, AppError> {
|
|
let (provider_id, model_id) = model_ref
|
|
.split_once('/')
|
|
.ok_or_else(|| AppError::InvalidModelRef(model_ref.to_string()))?;
|
|
let model = ModelRef::new(provider_id, model_id);
|
|
let now = now_ms();
|
|
let session = Session::new_root(agent, model, now);
|
|
let session_id = session.id.clone();
|
|
self.inner.store.upsert_session(session.clone()).await?;
|
|
self.inner.bus.publish(AppEvent::SessionCreated { session });
|
|
Ok(session_id)
|
|
}
|
|
|
|
pub async fn prompt(
|
|
&self,
|
|
session_id: SessionId,
|
|
text: String,
|
|
model_ref: &str,
|
|
) -> Result<(), AppError> {
|
|
let (provider_id, model_id) = model_ref
|
|
.split_once('/')
|
|
.ok_or_else(|| AppError::InvalidModelRef(model_ref.to_string()))?;
|
|
let provider = self
|
|
.inner
|
|
.providers
|
|
.get(provider_id)
|
|
.ok_or_else(|| AppError::UnknownProvider(provider_id.to_string()))?;
|
|
let model = ModelRef::new(provider_id, model_id);
|
|
|
|
{
|
|
let runs = self.inner.runs.lock().unwrap();
|
|
if runs.contains_key(&session_id) {
|
|
return Err(AppError::SessionRunning(session_id));
|
|
}
|
|
}
|
|
|
|
let now = now_ms();
|
|
let user_message = Message::new_user(session_id.clone(), now);
|
|
self.inner
|
|
.store
|
|
.upsert_message(user_message.clone())
|
|
.await?;
|
|
// The store actor does not emit events, so publish the user message and its part
|
|
// ourselves — otherwise the TUI (which builds its live transcript purely from bus
|
|
// events) never shows the prompt the user just typed until the session is reloaded.
|
|
self.inner.bus.publish(AppEvent::MessageCreated {
|
|
message: user_message.clone(),
|
|
});
|
|
|
|
let user_part = Part {
|
|
id: PartId::new(),
|
|
message_id: user_message.id.clone(),
|
|
session_id: session_id.clone(),
|
|
idx: 0,
|
|
body: PartBody::Text {
|
|
text,
|
|
synthetic: false,
|
|
},
|
|
};
|
|
self.inner.store.upsert_part(user_part.clone()).await?;
|
|
self.inner
|
|
.bus
|
|
.publish(AppEvent::PartUpdated { part: user_part });
|
|
|
|
// Resolve the session's agent from the registry (falls back to a generic assistant).
|
|
let session_agent = self
|
|
.inner
|
|
.store
|
|
.session(session_id.clone())
|
|
.await?
|
|
.map(|s| s.agent)
|
|
.unwrap_or_else(|| "orchestrator".to_string());
|
|
let agent = self.inner.agent_def(&session_agent);
|
|
let inject_job_board = self.inner.config.orchestration.job_board && agent.mode.is_primary();
|
|
let board = self.inner.board_for(&session_id).await?;
|
|
|
|
let ctx = StepContext {
|
|
store: self.inner.store.clone(),
|
|
bus: self.inner.bus.clone(),
|
|
tools: self.inner.filtered_tools(&agent),
|
|
permissions: self.inner.permissions.clone(),
|
|
static_rules: self.inner.effective_static_rules(&agent, 0),
|
|
extra_rules: Arc::new(Mutex::new(Vec::new())),
|
|
parent_rules: Vec::new(), // root session: no intersection
|
|
session_id: session_id.clone(),
|
|
cwd: self.inner.cwd.clone(),
|
|
data_dir: self.inner.data_dir.join(session_id.to_string()),
|
|
cancel: CancellationToken::new(),
|
|
now,
|
|
spawner: Some(self.inner.clone()),
|
|
job_board: Some(board),
|
|
context_reporter: None, // root session has no parent board to report to
|
|
};
|
|
let run_config = RunConfig {
|
|
agent_name: agent.name.clone(),
|
|
agent_prompt: self.inner.agent_prompt(&agent),
|
|
model,
|
|
temperature: agent.temperature,
|
|
max_steps: agent.max_steps.unwrap_or(DEFAULT_MAX_STEPS),
|
|
instructions: self.inner.config.instructions.clone(),
|
|
cost: Some(self.inner.catalog.cost(provider_id, model_id)),
|
|
inject_job_board,
|
|
reminder_turn_start: self.inner.reminder("turn_start"),
|
|
reminder_after_file_tool: self.inner.reminder("after_file_tool"),
|
|
};
|
|
|
|
// Reserve the run slot *before* spawning. If we inserted after spawning, a run that
|
|
// finished quickly could remove its (not-yet-inserted) entry first, and our later
|
|
// insert would then strand the session as permanently "running".
|
|
let cancel = ctx.cancel.clone();
|
|
{
|
|
let mut runs = self.inner.runs.lock().unwrap();
|
|
if runs.contains_key(&session_id) {
|
|
return Err(AppError::SessionRunning(session_id));
|
|
}
|
|
runs.insert(
|
|
session_id.clone(),
|
|
RunHandle {
|
|
cancel: cancel.clone(),
|
|
},
|
|
);
|
|
}
|
|
|
|
self.inner.bus.publish(AppEvent::RunStarted {
|
|
session_id: session_id.clone(),
|
|
});
|
|
|
|
let inner = self.inner.clone();
|
|
let spawn_session_id = session_id.clone();
|
|
tokio::spawn(async move {
|
|
let outcome = run_session(provider, ctx, &run_config, now_ms).await;
|
|
inner.bus.publish(AppEvent::RunFinished {
|
|
session_id: spawn_session_id.clone(),
|
|
outcome: outcome.clone(),
|
|
});
|
|
let mut runs = inner.runs.lock().unwrap();
|
|
runs.remove(&spawn_session_id);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn abort(&self, session_id: &SessionId) {
|
|
let runs = self.inner.runs.lock().unwrap();
|
|
if let Some(run) = runs.get(session_id) {
|
|
run.cancel.cancel();
|
|
}
|
|
}
|
|
|
|
pub fn permission_reply(&self, id: &str, reply: PermissionReply) -> bool {
|
|
self.inner.permissions.reply(id, reply)
|
|
}
|
|
|
|
pub async fn list_sessions(&self) -> Result<Vec<Session>, AppError> {
|
|
Ok(self.inner.store.sessions().await?)
|
|
}
|
|
|
|
pub async fn session_messages(&self, session_id: SessionId) -> Result<Vec<Message>, AppError> {
|
|
Ok(self.inner.store.messages(session_id).await?)
|
|
}
|
|
|
|
pub async fn message_parts(&self, message_id: MessageId) -> Result<Vec<Part>, AppError> {
|
|
Ok(self.inner.store.parts(message_id).await?)
|
|
}
|
|
|
|
pub async fn get_session(&self, session_id: SessionId) -> Result<Option<Session>, AppError> {
|
|
Ok(self.inner.store.session(session_id).await?)
|
|
}
|
|
|
|
/// Background jobs spawned by `session_id` (the parent), for the TUI jobs pane / drill-in.
|
|
pub async fn jobs(
|
|
&self,
|
|
session_id: SessionId,
|
|
) -> Result<Vec<harness_core::engine::JobRecord>, AppError> {
|
|
Ok(self.inner.store.jobs_for_parent(session_id).await?)
|
|
}
|
|
|
|
pub fn is_running(&self, session_id: &SessionId) -> bool {
|
|
let runs = self.inner.runs.lock().unwrap();
|
|
runs.contains_key(session_id)
|
|
}
|
|
|
|
pub async fn final_text(&self, session_id: &SessionId) -> Result<String, AppError> {
|
|
let messages = self.inner.store.messages(session_id.clone()).await?;
|
|
let Some(last) = messages.last() else {
|
|
return Ok(String::new());
|
|
};
|
|
let parts = self.inner.store.parts(last.id.clone()).await?;
|
|
let text = parts
|
|
.iter()
|
|
.filter_map(|p| match &p.body {
|
|
PartBody::Text { text, .. } => Some(text.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("");
|
|
Ok(text)
|
|
}
|
|
}
|
|
|
|
/// Deny rules injected as low-priority defaults for any session at depth > 0: a subagent may
|
|
/// not itself delegate (`task`) or manage todos unless config/agent rules re-enable it.
|
|
fn depth_guard_rules() -> Ruleset {
|
|
["task", "todo", "todo*"]
|
|
.into_iter()
|
|
.map(|perm| Rule {
|
|
permission: perm.to_string(),
|
|
pattern: "*".to_string(),
|
|
action: harness_core::permission::Action::Deny,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
impl EngineInner {
|
|
fn arc(&self) -> Arc<EngineInner> {
|
|
self.me
|
|
.get()
|
|
.and_then(Weak::upgrade)
|
|
.expect("EngineInner self-reference set in new()")
|
|
}
|
|
|
|
/// Resolves an agent definition by name, falling back to a generic assistant so an
|
|
/// unknown/misconfigured agent still runs rather than failing the session.
|
|
fn agent_def(&self, name: &str) -> AgentDef {
|
|
self.agents.get(name).cloned().unwrap_or_else(|| AgentDef {
|
|
name: name.to_string(),
|
|
description: String::new(),
|
|
mode: harness_core::agent::AgentMode::Primary,
|
|
model: None,
|
|
temperature: None,
|
|
prompt: String::new(),
|
|
permissions: Vec::new(),
|
|
tools: std::collections::HashMap::new(),
|
|
max_steps: None,
|
|
source: harness_core::agent::AgentSource::Config,
|
|
})
|
|
}
|
|
|
|
/// A tool registry filtered to what `agent` enables. Exact tool names override a `*`
|
|
/// wildcard; absent = enabled. Honors the `tools:` frontmatter map (docs/04-multiagent.md).
|
|
fn filtered_tools(&self, agent: &AgentDef) -> ToolRegistry {
|
|
if agent.tools.is_empty() {
|
|
return self.tools.clone();
|
|
}
|
|
let mut reg = ToolRegistry::new();
|
|
for tool in self.tools.all() {
|
|
let name = tool.name();
|
|
let enabled = agent
|
|
.tools
|
|
.get(name)
|
|
.or_else(|| agent.tools.get("*"))
|
|
.copied()
|
|
.unwrap_or(true);
|
|
if enabled {
|
|
reg.register(tool);
|
|
}
|
|
}
|
|
reg
|
|
}
|
|
|
|
fn agent_prompt(&self, agent: &AgentDef) -> String {
|
|
if agent.prompt.trim().is_empty() {
|
|
DEFAULT_AGENT_PROMPT.to_string()
|
|
} else {
|
|
agent.prompt.clone()
|
|
}
|
|
}
|
|
|
|
/// An optional orchestration reminder string by hook key (`turn_start`/`after_file_tool`).
|
|
fn reminder(&self, key: &str) -> Option<String> {
|
|
self.config
|
|
.orchestration
|
|
.reminders
|
|
.as_ref()?
|
|
.get(key)
|
|
.cloned()
|
|
}
|
|
|
|
/// The ruleset a session evaluates its own tool calls against: low-priority depth guards
|
|
/// (depth > 0), then global config permissions, then the agent's own rules (highest).
|
|
fn effective_static_rules(&self, agent: &AgentDef, depth: u8) -> Ruleset {
|
|
let mut rules = Ruleset::new();
|
|
if depth > 0 {
|
|
rules.extend(depth_guard_rules());
|
|
}
|
|
rules.extend(self.config.permissions.clone());
|
|
rules.extend(agent.permissions.clone());
|
|
rules
|
|
}
|
|
|
|
/// The parent-effective ruleset a *child* intersects against: global config, the parent
|
|
/// agent's rules, then the parent session's `Always` grants. Frozen at spawn time.
|
|
fn parent_effective_rules(&self, session: &Session, agent: &AgentDef) -> Ruleset {
|
|
let mut rules = self.config.permissions.clone();
|
|
rules.extend(agent.permissions.clone());
|
|
rules.extend(session.extra_rules.clone());
|
|
rules
|
|
}
|
|
|
|
/// Gets (or lazily loads) the job board scoped to `session_id` as a parent.
|
|
async fn board_for(&self, session_id: &SessionId) -> Result<Arc<JobBoard>, StoreError> {
|
|
if let Some(board) = self.boards.lock().unwrap().get(session_id).cloned() {
|
|
return Ok(board);
|
|
}
|
|
let board = Arc::new(
|
|
JobBoard::load(
|
|
self.store.clone(),
|
|
self.bus.clone(),
|
|
session_id,
|
|
self.config.orchestration.max_reusable_per_agent,
|
|
)
|
|
.await?,
|
|
);
|
|
// Double-check under lock in case a concurrent caller inserted first.
|
|
let mut boards = self.boards.lock().unwrap();
|
|
Ok(boards.entry(session_id.clone()).or_insert(board).clone())
|
|
}
|
|
|
|
/// Appends `text` as a user message + text part to `session_id` (mirrors the root prompt
|
|
/// path so a child session's first turn has an instruction to act on).
|
|
async fn append_user_message(
|
|
&self,
|
|
session_id: &SessionId,
|
|
text: String,
|
|
now: i64,
|
|
) -> Result<(), StoreError> {
|
|
let message = Message::new_user(session_id.clone(), now);
|
|
self.store.upsert_message(message.clone()).await?;
|
|
self.bus.publish(AppEvent::MessageCreated {
|
|
message: message.clone(),
|
|
});
|
|
let part = Part {
|
|
id: PartId::new(),
|
|
message_id: message.id.clone(),
|
|
session_id: session_id.clone(),
|
|
idx: 0,
|
|
body: PartBody::Text {
|
|
text,
|
|
synthetic: false,
|
|
},
|
|
};
|
|
self.store.upsert_part(part.clone()).await?;
|
|
self.bus.publish(AppEvent::PartUpdated { part });
|
|
Ok(())
|
|
}
|
|
|
|
/// Concatenates the `Text` parts of a session's last message — a child run's final answer.
|
|
async fn session_final_text(&self, session_id: &SessionId) -> String {
|
|
let Ok(messages) = self.store.messages(session_id.clone()).await else {
|
|
return String::new();
|
|
};
|
|
let Some(last) = messages.last() else {
|
|
return String::new();
|
|
};
|
|
let Ok(parts) = self.store.parts(last.id.clone()).await else {
|
|
return String::new();
|
|
};
|
|
parts
|
|
.iter()
|
|
.filter_map(|p| match &p.body {
|
|
PartBody::Text { text, .. } => Some(text.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("")
|
|
}
|
|
|
|
/// Builds a child session's step context (permission intersection + its own board).
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn child_ctx(
|
|
&self,
|
|
session_id: SessionId,
|
|
tools: ToolRegistry,
|
|
static_rules: Ruleset,
|
|
parent_rules: Ruleset,
|
|
extra_rules: Ruleset,
|
|
board: Arc<JobBoard>,
|
|
reporter: Option<Arc<dyn ContextReporter>>,
|
|
cancel: CancellationToken,
|
|
now: i64,
|
|
) -> StepContext {
|
|
StepContext {
|
|
store: self.store.clone(),
|
|
bus: self.bus.clone(),
|
|
tools,
|
|
permissions: self.permissions.clone(),
|
|
static_rules,
|
|
extra_rules: Arc::new(Mutex::new(extra_rules)),
|
|
parent_rules,
|
|
session_id: session_id.clone(),
|
|
cwd: self.cwd.clone(),
|
|
data_dir: self.data_dir.join(session_id.to_string()),
|
|
cancel,
|
|
now,
|
|
spawner: Some(self.arc()),
|
|
job_board: Some(board),
|
|
context_reporter: reporter,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reports a child session's file reads to its job on the parent board.
|
|
struct BoardReporter {
|
|
board: Arc<JobBoard>,
|
|
task_id: String,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ContextReporter for BoardReporter {
|
|
async fn report_file(&self, path: String, lines: u32) {
|
|
let _ = self
|
|
.board
|
|
.report_context_file(&self.task_id, path, lines, now_ms())
|
|
.await;
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SubagentSpawner for EngineInner {
|
|
async fn spawn(&self, req: SpawnRequest) -> Result<SpawnOutcome, SpawnError> {
|
|
let other = |e: StoreError| SpawnError::Other(e.to_string());
|
|
|
|
// 1. Load the parent and enforce the depth limit.
|
|
let parent = self
|
|
.store
|
|
.session(req.parent_session_id.clone())
|
|
.await
|
|
.map_err(other)?
|
|
.ok_or_else(|| SpawnError::Other("parent session not found".into()))?;
|
|
let child_depth = parent.depth + 1;
|
|
if child_depth as u32 > self.config.orchestration.depth_limit {
|
|
return Err(SpawnError::DepthExceeded);
|
|
}
|
|
|
|
// 2. Resolve the agent — must be usable as a subagent.
|
|
let agent = self
|
|
.agents
|
|
.get(&req.agent)
|
|
.filter(|a| a.mode.is_subagent())
|
|
.cloned()
|
|
.ok_or_else(|| SpawnError::InvalidAgent(req.agent.clone()))?;
|
|
|
|
// Parent-effective rules for permission intersection (frozen snapshot).
|
|
let parent_agent = self.agent_def(&parent.agent);
|
|
let parent_rules = self.parent_effective_rules(&parent, &parent_agent);
|
|
|
|
let now = now_ms();
|
|
let board = self
|
|
.board_for(&req.parent_session_id)
|
|
.await
|
|
.map_err(other)?;
|
|
|
|
// 3. Reuse a completed child session by alias/id, or create a fresh one.
|
|
let (child_session, task_id, reused) = if let Some(alias) = &req.reuse_task_id {
|
|
let job = board
|
|
.resolve_reusable(&req.parent_session_id, alias)
|
|
.ok_or_else(|| SpawnError::ReuseNotFound(alias.clone()))?;
|
|
let child = self
|
|
.store
|
|
.session(job.child_session.clone())
|
|
.await
|
|
.map_err(other)?
|
|
.ok_or_else(|| SpawnError::ReuseNotFound(alias.clone()))?;
|
|
let _ = board.touch(&job.task_id, now).await;
|
|
(child, job.task_id, true)
|
|
} else {
|
|
let mut child = Session::new_child(&parent, req.agent.clone(), now);
|
|
if let Some(model) = &agent.model {
|
|
child.model = model.clone();
|
|
}
|
|
self.store
|
|
.upsert_session(child.clone())
|
|
.await
|
|
.map_err(other)?;
|
|
self.bus.publish(AppEvent::SessionCreated {
|
|
session: child.clone(),
|
|
});
|
|
let task_id = child.id.to_string();
|
|
(child, task_id, false)
|
|
};
|
|
let child_id = child_session.id.clone();
|
|
|
|
// 4. Append the instruction as the child's next user turn.
|
|
self.append_user_message(&child_id, req.prompt.clone(), now)
|
|
.await
|
|
.map_err(other)?;
|
|
|
|
// 5. Resolve the provider for the child model.
|
|
let provider = self
|
|
.providers
|
|
.get(&child_session.model.provider_id)
|
|
.ok_or_else(|| {
|
|
SpawnError::Other(format!(
|
|
"no provider registered for {:?}",
|
|
child_session.model.provider_id
|
|
))
|
|
})?;
|
|
|
|
let child_static = self.effective_static_rules(&agent, child_depth);
|
|
let child_tools = self.filtered_tools(&agent);
|
|
let child_board = self.board_for(&child_id).await.map_err(other)?;
|
|
let run_config = RunConfig {
|
|
agent_name: agent.name.clone(),
|
|
agent_prompt: self.agent_prompt(&agent),
|
|
model: child_session.model.clone(),
|
|
temperature: agent.temperature,
|
|
max_steps: agent.max_steps.unwrap_or(DEFAULT_MAX_STEPS),
|
|
instructions: self.config.instructions.clone(),
|
|
cost: Some(self.catalog.cost(
|
|
&child_session.model.provider_id,
|
|
&child_session.model.model_id,
|
|
)),
|
|
inject_job_board: agent.mode.is_primary(),
|
|
reminder_turn_start: self.reminder("turn_start"),
|
|
reminder_after_file_tool: self.reminder("after_file_tool"),
|
|
};
|
|
|
|
// Register the launch on the parent board (also makes foreground results reusable).
|
|
if !reused {
|
|
let _ = board
|
|
.register_launch(
|
|
LaunchSpec {
|
|
task_id: task_id.clone(),
|
|
parent_session: req.parent_session_id.clone(),
|
|
child_session: child_id.clone(),
|
|
agent: req.agent.clone(),
|
|
description: req.description.clone(),
|
|
objective: Some(req.description.clone()),
|
|
},
|
|
now,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
if req.background {
|
|
// Child of the parent session's *run* token so the job dies with the session, not
|
|
// when the (already-returned) tool call completes.
|
|
let cancel = {
|
|
let runs = self.runs.lock().unwrap();
|
|
runs.get(&req.parent_session_id)
|
|
.map(|r| r.cancel.child_token())
|
|
}
|
|
.unwrap_or_default();
|
|
|
|
let reporter: Arc<dyn ContextReporter> = Arc::new(BoardReporter {
|
|
board: board.clone(),
|
|
task_id: task_id.clone(),
|
|
});
|
|
let ctx = self.child_ctx(
|
|
child_id.clone(),
|
|
child_tools,
|
|
child_static,
|
|
parent_rules,
|
|
child_session.extra_rules.clone(),
|
|
child_board,
|
|
Some(reporter),
|
|
cancel,
|
|
now,
|
|
);
|
|
let this = self.arc();
|
|
let board_bg = board.clone();
|
|
let task_id_bg = task_id.clone();
|
|
let child_id_bg = child_id.clone();
|
|
tokio::spawn(async move {
|
|
let outcome = run_session(provider, ctx, &run_config, now_ms).await;
|
|
let (state, summary) = match &outcome {
|
|
RunOutcome::Stopped => (
|
|
JobState::Completed,
|
|
Some(this.session_final_text(&child_id_bg).await),
|
|
),
|
|
RunOutcome::Aborted => (JobState::Cancelled, None),
|
|
RunOutcome::Errored { message } => (JobState::Error, Some(message.clone())),
|
|
};
|
|
let _ = board_bg.finish(&task_id_bg, state, summary, now_ms()).await;
|
|
});
|
|
|
|
let alias = board
|
|
.snapshot()
|
|
.into_iter()
|
|
.find(|j| j.task_id == task_id)
|
|
.map(|j| j.alias);
|
|
Ok(SpawnOutcome {
|
|
child_session_id: child_id,
|
|
background: true,
|
|
alias,
|
|
final_text: None,
|
|
})
|
|
} else {
|
|
// Foreground: run to completion under the tool call's token and return the text.
|
|
let reporter: Arc<dyn ContextReporter> = Arc::new(BoardReporter {
|
|
board: board.clone(),
|
|
task_id: task_id.clone(),
|
|
});
|
|
let ctx = self.child_ctx(
|
|
child_id.clone(),
|
|
child_tools,
|
|
child_static,
|
|
parent_rules,
|
|
child_session.extra_rules.clone(),
|
|
child_board,
|
|
Some(reporter),
|
|
req.cancel.clone(),
|
|
now,
|
|
);
|
|
let outcome = run_session(provider, ctx, &run_config, now_ms).await;
|
|
let final_text = self.session_final_text(&child_id).await;
|
|
let (state, summary) = match &outcome {
|
|
RunOutcome::Stopped => (JobState::Completed, Some(final_text.clone())),
|
|
RunOutcome::Aborted => (JobState::Cancelled, None),
|
|
RunOutcome::Errored { message } => (JobState::Error, Some(message.clone())),
|
|
};
|
|
let _ = board.finish(&task_id, state, summary, now_ms()).await;
|
|
Ok(SpawnOutcome {
|
|
child_session_id: child_id,
|
|
background: false,
|
|
alias: None,
|
|
final_text: Some(final_text),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct App {
|
|
engine: EngineHandle,
|
|
// Keeps the auto-approve task alive for the lifetime of the App.
|
|
_auto_approve_handle: Option<JoinHandle<()>>,
|
|
}
|
|
|
|
impl App {
|
|
/// Persistent SQLite-backed store with an auto-approve permission frontend — the default
|
|
/// headless configuration used by `harness run -p`.
|
|
pub fn init(cwd: PathBuf) -> Result<Self, AppError> {
|
|
let engine = EngineHandle::init(cwd)?;
|
|
let _auto_approve_handle = Some(spawn_auto_approve_task(&engine));
|
|
Ok(Self {
|
|
engine,
|
|
_auto_approve_handle,
|
|
})
|
|
}
|
|
|
|
/// In-memory store with an auto-approve permission frontend — useful for tests.
|
|
pub fn init_in_memory(cwd: PathBuf) -> Result<Self, AppError> {
|
|
let engine = EngineHandle::init_in_memory(cwd)?;
|
|
let _auto_approve_handle = Some(spawn_auto_approve_task(&engine));
|
|
Ok(Self {
|
|
engine,
|
|
_auto_approve_handle,
|
|
})
|
|
}
|
|
|
|
pub fn engine(&self) -> &EngineHandle {
|
|
&self.engine
|
|
}
|
|
|
|
pub fn config(&self) -> Config {
|
|
self.engine.config()
|
|
}
|
|
|
|
/// Runs a single headless turn: creates a root session, appends `prompt` as the user
|
|
/// message, and drives the engine loop to completion. Returns the outcome plus the
|
|
/// session id so the caller can fetch the transcript via `final_text`.
|
|
pub async fn run_prompt(
|
|
&self,
|
|
prompt: String,
|
|
model_ref: &str,
|
|
) -> Result<(RunOutcome, SessionId), AppError> {
|
|
let session_id = self.engine.new_session("orchestrator", model_ref).await?;
|
|
let mut rx = self.engine.bus().subscribe();
|
|
// Subscribe before starting the run so we cannot miss the RunFinished event
|
|
// even for an instant (mock) provider.
|
|
self.engine
|
|
.prompt(session_id.clone(), prompt, model_ref)
|
|
.await?;
|
|
|
|
while let Ok(event) = rx.recv().await {
|
|
if let AppEvent::RunFinished {
|
|
session_id: sid,
|
|
outcome,
|
|
} = event
|
|
{
|
|
if sid == session_id {
|
|
return Ok((outcome, session_id));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bus closed unexpectedly; report a stopped run so the caller can continue.
|
|
Ok((RunOutcome::Stopped, session_id))
|
|
}
|
|
|
|
/// Concatenates the `Text` parts of the last message in the session — the final
|
|
/// assistant reply for a `harness run` invocation to print.
|
|
pub async fn final_text(&self, session_id: &SessionId) -> Result<String, AppError> {
|
|
self.engine.final_text(session_id).await
|
|
}
|
|
}
|
|
|
|
fn spawn_auto_approve_task(engine: &EngineHandle) -> JoinHandle<()> {
|
|
let bus = engine.bus();
|
|
let permissions = engine.permissions();
|
|
tokio::spawn(async move {
|
|
let mut rx = bus.subscribe();
|
|
while let Ok(event) = rx.recv().await {
|
|
if let AppEvent::PermissionAsked { request } = event {
|
|
permissions.reply(&request.id, PermissionReply::Once);
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use async_trait::async_trait;
|
|
use harness_core::llm::{
|
|
FinishReason, LlmEvent, LlmEventStream, LlmRequest, Provider, ProviderError,
|
|
};
|
|
use harness_core::types::{ModelInfo, TokenUsage};
|
|
|
|
/// Emits a single final-text turn for every stream call — enough to drive a child
|
|
/// subagent session to completion deterministically.
|
|
struct MockProvider;
|
|
|
|
#[async_trait]
|
|
impl Provider for MockProvider {
|
|
fn id(&self) -> &str {
|
|
"mock"
|
|
}
|
|
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
|
|
Ok(vec![])
|
|
}
|
|
async fn stream(
|
|
&self,
|
|
_req: LlmRequest,
|
|
_cancel: CancellationToken,
|
|
) -> Result<LlmEventStream, ProviderError> {
|
|
let events = vec![
|
|
Ok(LlmEvent::TextStart { id: "t".into() }),
|
|
Ok(LlmEvent::TextDelta {
|
|
id: "t".into(),
|
|
text: "explored the code".into(),
|
|
}),
|
|
Ok(LlmEvent::TextEnd { id: "t".into() }),
|
|
Ok(LlmEvent::Finish {
|
|
reason: FinishReason::Stop,
|
|
usage: TokenUsage::default(),
|
|
}),
|
|
];
|
|
Ok(Box::pin(futures::stream::iter(events)))
|
|
}
|
|
}
|
|
|
|
fn mock_engine(cwd: PathBuf) -> EngineHandle {
|
|
let mut providers = ProviderRegistry::new();
|
|
providers.register(Arc::new(MockProvider));
|
|
EngineHandle::build(
|
|
cwd,
|
|
Store::open_in_memory().unwrap(),
|
|
Config::default(),
|
|
providers,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
/// Drives a subagent through one `read` tool call, then a final answer.
|
|
struct ReadThenAnswerProvider {
|
|
steps: std::sync::Mutex<std::collections::VecDeque<Vec<Result<LlmEvent, ProviderError>>>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for ReadThenAnswerProvider {
|
|
fn id(&self) -> &str {
|
|
"mock"
|
|
}
|
|
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
|
|
Ok(vec![])
|
|
}
|
|
async fn stream(
|
|
&self,
|
|
_req: LlmRequest,
|
|
_cancel: CancellationToken,
|
|
) -> Result<LlmEventStream, ProviderError> {
|
|
let events = self.steps.lock().unwrap().pop_front().unwrap_or_else(|| {
|
|
vec![
|
|
Ok(LlmEvent::TextStart { id: "d".into() }),
|
|
Ok(LlmEvent::TextDelta {
|
|
id: "d".into(),
|
|
text: "done".into(),
|
|
}),
|
|
Ok(LlmEvent::TextEnd { id: "d".into() }),
|
|
Ok(LlmEvent::Finish {
|
|
reason: FinishReason::Stop,
|
|
usage: TokenUsage::default(),
|
|
}),
|
|
]
|
|
});
|
|
Ok(Box::pin(futures::stream::iter(events)))
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn subagent_file_reads_land_on_the_job_board() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
// A file with ≥10 lines so the read clears the board's reporting threshold.
|
|
let body = (1..=15)
|
|
.map(|n| format!("line {n}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
std::fs::write(dir.path().join("auth.rs"), body).unwrap();
|
|
|
|
let provider = Arc::new(ReadThenAnswerProvider {
|
|
steps: std::sync::Mutex::new(
|
|
vec![
|
|
// Turn 1: call the read tool.
|
|
vec![
|
|
Ok(LlmEvent::ToolCall {
|
|
call_id: "c1".into(),
|
|
name: "read".into(),
|
|
input: serde_json::json!({"file_path": "auth.rs"}),
|
|
}),
|
|
Ok(LlmEvent::Finish {
|
|
reason: FinishReason::ToolCalls,
|
|
usage: TokenUsage::default(),
|
|
}),
|
|
],
|
|
// Turn 2: final answer.
|
|
vec![
|
|
Ok(LlmEvent::TextStart { id: "t".into() }),
|
|
Ok(LlmEvent::TextDelta {
|
|
id: "t".into(),
|
|
text: "found it".into(),
|
|
}),
|
|
Ok(LlmEvent::TextEnd { id: "t".into() }),
|
|
Ok(LlmEvent::Finish {
|
|
reason: FinishReason::Stop,
|
|
usage: TokenUsage::default(),
|
|
}),
|
|
],
|
|
]
|
|
.into(),
|
|
),
|
|
});
|
|
let mut providers = ProviderRegistry::new();
|
|
providers.register(provider);
|
|
let engine = EngineHandle::build(
|
|
dir.path().to_path_buf(),
|
|
Store::open_in_memory().unwrap(),
|
|
Config::default(),
|
|
providers,
|
|
)
|
|
.unwrap();
|
|
// Auto-approve permission asks (the child's read tool gates on `read`).
|
|
spawn_auto_approve_task(&engine);
|
|
|
|
let root = engine
|
|
.new_session("orchestrator", "mock/mock-model")
|
|
.await
|
|
.unwrap();
|
|
let out = engine
|
|
.inner
|
|
.spawn(SpawnRequest {
|
|
parent_session_id: root.clone(),
|
|
parent_message_id: MessageId::new(),
|
|
agent: "explorer".into(),
|
|
description: "read auth".into(),
|
|
prompt: "read auth.rs".into(),
|
|
reuse_task_id: None,
|
|
background: false,
|
|
cancel: CancellationToken::new(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let board = engine.inner.board_for(&root).await.unwrap();
|
|
let job = board
|
|
.snapshot()
|
|
.into_iter()
|
|
.find(|j| j.child_session == out.child_session_id)
|
|
.expect("job on board");
|
|
assert!(
|
|
job.context_files.iter().any(|f| f.path == "auth.rs"),
|
|
"expected auth.rs on the board, got {:?}",
|
|
job.context_files
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn foreground_task_runs_a_subagent_and_alias_reuse_continues_the_same_session() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let engine = mock_engine(dir.path().to_path_buf());
|
|
let root = engine
|
|
.new_session("orchestrator", "mock/mock-model")
|
|
.await
|
|
.unwrap();
|
|
|
|
// Foreground spawn of the explorer subagent.
|
|
let out = engine
|
|
.inner
|
|
.spawn(SpawnRequest {
|
|
parent_session_id: root.clone(),
|
|
parent_message_id: MessageId::new(),
|
|
agent: "explorer".into(),
|
|
description: "map the auth flow".into(),
|
|
prompt: "find the login handler".into(),
|
|
reuse_task_id: None,
|
|
background: false,
|
|
cancel: CancellationToken::new(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(!out.background);
|
|
assert_eq!(out.final_text.as_deref(), Some("explored the code"));
|
|
let child_id = out.child_session_id.clone();
|
|
|
|
// The completed job is on the parent board, reusable by alias.
|
|
let board = engine.inner.board_for(&root).await.unwrap();
|
|
let job = board
|
|
.snapshot()
|
|
.into_iter()
|
|
.find(|j| j.child_session == child_id)
|
|
.expect("job registered");
|
|
assert_eq!(job.alias, "exp-1");
|
|
assert_eq!(job.state, JobState::Completed);
|
|
|
|
let child_messages_before = engine
|
|
.inner
|
|
.store
|
|
.messages(child_id.clone())
|
|
.await
|
|
.unwrap()
|
|
.len();
|
|
|
|
// Reuse by alias → same child session, with the new prompt appended.
|
|
let reused = engine
|
|
.inner
|
|
.spawn(SpawnRequest {
|
|
parent_session_id: root.clone(),
|
|
parent_message_id: MessageId::new(),
|
|
agent: "explorer".into(),
|
|
description: "follow up".into(),
|
|
prompt: "now find the logout handler".into(),
|
|
reuse_task_id: Some("exp-1".into()),
|
|
background: false,
|
|
cancel: CancellationToken::new(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
reused.child_session_id, child_id,
|
|
"reuse must continue the same child session"
|
|
);
|
|
|
|
let child_messages_after = engine.inner.store.messages(child_id).await.unwrap().len();
|
|
assert!(
|
|
child_messages_after > child_messages_before,
|
|
"reused session should have the follow-up turn appended ({child_messages_before} -> {child_messages_after})"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn depth_limit_is_enforced() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let engine = mock_engine(dir.path().to_path_buf());
|
|
// Build a session already at the depth limit so any child would exceed it.
|
|
let now = now_ms();
|
|
let mut deep = Session::new_root("explorer", ModelRef::new("mock", "mock-model"), now);
|
|
deep.depth = engine.inner.config.orchestration.depth_limit as u8;
|
|
engine
|
|
.inner
|
|
.store
|
|
.upsert_session(deep.clone())
|
|
.await
|
|
.unwrap();
|
|
|
|
let err = engine
|
|
.inner
|
|
.spawn(SpawnRequest {
|
|
parent_session_id: deep.id.clone(),
|
|
parent_message_id: MessageId::new(),
|
|
agent: "explorer".into(),
|
|
description: "too deep".into(),
|
|
prompt: "go deeper".into(),
|
|
reuse_task_id: None,
|
|
background: false,
|
|
cancel: CancellationToken::new(),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, SpawnError::DepthExceeded));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_subagent_is_rejected() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let engine = mock_engine(dir.path().to_path_buf());
|
|
let root = engine
|
|
.new_session("orchestrator", "mock/mock-model")
|
|
.await
|
|
.unwrap();
|
|
let err = engine
|
|
.inner
|
|
.spawn(SpawnRequest {
|
|
parent_session_id: root,
|
|
parent_message_id: MessageId::new(),
|
|
agent: "nonexistent".into(),
|
|
description: "x".into(),
|
|
prompt: "y".into(),
|
|
reuse_task_id: None,
|
|
background: false,
|
|
cancel: CancellationToken::new(),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, SpawnError::InvalidAgent(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn init_loads_defaults_with_no_providers_configured() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
|
let app = App::init_in_memory(dir.path().to_path_buf()).unwrap();
|
|
assert!(app.engine.inner.providers.get("anthropic").is_none());
|
|
// 6 built-ins + the multiagent `task` tool.
|
|
assert_eq!(app.engine.inner.tools.all().len(), 7);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_prompt_errors_on_unregistered_provider() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
|
let app = App::init_in_memory(dir.path().to_path_buf()).unwrap();
|
|
let err = app
|
|
.run_prompt("hi".into(), "anthropic/claude-sonnet-4-5")
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, AppError::UnknownProvider(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_prompt_errors_on_malformed_model_ref() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let app = App::init_in_memory(dir.path().to_path_buf()).unwrap();
|
|
let err = app.run_prompt("hi".into(), "no-slash").await.unwrap_err();
|
|
assert!(matches!(err, AppError::InvalidModelRef(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn final_text_is_empty_for_unknown_session() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let app = App::init_in_memory(dir.path().to_path_buf()).unwrap();
|
|
let text = app.final_text(&SessionId::new()).await.unwrap();
|
|
assert_eq!(text, "");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn engine_init_in_memory_creates_handle() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap();
|
|
assert!(!engine.config().providers.contains_key("anthropic"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn engine_new_session_persists_session() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap();
|
|
let id = engine
|
|
.new_session("orchestrator", "anthropic/claude")
|
|
.await
|
|
.unwrap();
|
|
let sessions = engine.list_sessions().await.unwrap();
|
|
assert_eq!(sessions.len(), 1);
|
|
assert_eq!(sessions[0].id, id);
|
|
assert_eq!(sessions[0].agent, "orchestrator");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn engine_list_sessions_returns_persisted_sessions() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap();
|
|
let id1 = engine.new_session("a", "anthropic/claude").await.unwrap();
|
|
let id2 = engine.new_session("b", "anthropic/claude").await.unwrap();
|
|
let sessions = engine.list_sessions().await.unwrap();
|
|
assert_eq!(sessions.len(), 2);
|
|
assert!(sessions.iter().any(|s| s.id == id1));
|
|
assert!(sessions.iter().any(|s| s.id == id2));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn engine_prompt_errors_on_unregistered_provider() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
|
let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap();
|
|
let id = engine
|
|
.new_session("orchestrator", "anthropic/claude")
|
|
.await
|
|
.unwrap();
|
|
let err = engine
|
|
.prompt(id, "hi".into(), "anthropic/claude")
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, AppError::UnknownProvider(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn engine_prompt_errors_on_malformed_model_ref() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap();
|
|
let id = engine
|
|
.new_session("orchestrator", "anthropic/claude")
|
|
.await
|
|
.unwrap();
|
|
let err = engine
|
|
.prompt(id, "hi".into(), "no-slash")
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, AppError::InvalidModelRef(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn engine_is_running_false_for_inactive_session() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap();
|
|
let id = engine
|
|
.new_session("orchestrator", "anthropic/claude")
|
|
.await
|
|
.unwrap();
|
|
assert!(!engine.is_running(&id));
|
|
}
|
|
}
|