Files
openfang/crates/openfang-kernel/tests/workflow_integration_test.rs
T
Ben Hoverter b1c4061247 fix(runtime): wire subprocess_timeout_secs through config.toml
Follow-up to 79aa34c. The previous commit added the public surface
(DriverConfig field + OPENFANG_SUBPROCESS_TIMEOUT_SECS env var) but
left every DriverConfig construction site hardcoded to None — so the
struct field was wired but had no on-disk source feeding it. The env
var was the only operator-facing knob.

This commit plumbs the missing layer: the timeout is now deserializable
from config.toml on both the primary and global-fallback providers.

Public surface
- DefaultModelConfig.subprocess_timeout_secs: Option<u64>
- FallbackProviderConfig.subprocess_timeout_secs: Option<u64>
- Both fields are #[serde(default)] — existing config.toml files
  without the field deserialize cleanly to None (no breaking change).

Placement rationale
- Per-provider on each config struct, not a top-level field or a new
  [driver] section. This matches the existing per-provider config shape
  and lets operators set different timeouts for primary vs. fallback
  (e.g. tighter timeout on a fast fallback to fail over sooner). If a
  second driver-level setting ever lands, refactoring two struct fields
  into a [driver] section is cheap; we don't pre-pay for it now.

Wiring (kernel.rs)
- L663  primary driver  ........  pulls config.default_model.subprocess_timeout_secs
- L687  auto-detect path  ......  inherits default_model intent (the swap
                                  is replacing the *provider*, not the
                                  timeout policy)
- L736  global fallback loop  ..  pulls fb.subprocess_timeout_secs
- L5031 agent primary  .........  inherits effective_default's value when
                                  agent_provider == default_provider;
                                  None for cross-provider overrides
- L5108 agent manifest fallback   inherits dm's value when the manifest
                                  fallback resolves to "default" (matching
                                  the existing fb.provider sentinel logic);
                                  None for explicit cross-provider entries
- L5139 global fallback (per-agent loop) — pulls fb.subprocess_timeout_secs

Sites kept as None (intentional)
- agent_loop.rs:1146, 1330: ModelNotFound recovery iterates over the
  agent manifest's fallback_models (FallbackModel, not the config-toml
  type) — no per-provider config in scope.
- routes.rs:7701: provider connectivity test endpoint; no config source.
- routes.rs:7529: dashboard hot-update path constructs a fresh DM with
  defaults (None) — operator sets timeout via config.toml, not via the
  set-key flow.

Tests
- test_subprocess_timeout_secs_in_toml: round-trips a TOML doc with
  default_model.subprocess_timeout_secs = 600 and one fallback at 180,
  one fallback omitted; asserts each value (or None) reaches the parsed
  config struct.
- test_subprocess_timeout_secs_omitted_defaults_to_none: asserts a
  legacy-shaped config.toml (no timeout fields) parses cleanly with
  both fields = None — backward-compat guard.
- 4 existing claude_code driver timeout tests still pass.

Mechanical pass-throughs
- 8 test fixtures across openfang-kernel/tests and openfang-api/tests
  gain subprocess_timeout_secs: None on their DefaultModelConfig
  literals.
- 1 production literal in routes.rs gains the same field.
- The existing FallbackProviderConfig serde-roundtrip test gains
  subprocess_timeout_secs: None plus an assertion.

Precedence comment in drivers/mod.rs::create_driver updated to reflect
that the config-field path is now real, with explicit pointers to the
kernel.rs wiring sites for future contributors.

Validated: cargo check --workspace --tests is clean; openfang-types
(362), openfang-runtime (933), and openfang-kernel (260) lib tests
all pass.
2026-04-27 23:40:12 -07:00

406 lines
12 KiB
Rust

//! End-to-end workflow integration tests.
//!
//! Tests the full pipeline: boot kernel → spawn agents → create workflow →
//! execute workflow → verify outputs flow through the pipeline.
//!
//! LLM tests require GROQ_API_KEY. Non-LLM tests verify the kernel-level
//! workflow wiring without making real API calls.
use openfang_kernel::workflow::{
ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowStep,
};
use openfang_kernel::OpenFangKernel;
use openfang_types::agent::AgentManifest;
use openfang_types::config::{DefaultModelConfig, KernelConfig};
use std::sync::Arc;
fn test_config(provider: &str, model: &str, api_key_env: &str) -> KernelConfig {
let tmp = tempfile::tempdir().unwrap();
KernelConfig {
home_dir: tmp.path().to_path_buf(),
data_dir: tmp.path().join("data"),
default_model: DefaultModelConfig {
provider: provider.to_string(),
model: model.to_string(),
api_key_env: api_key_env.to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
}
}
fn spawn_test_agent(
kernel: &OpenFangKernel,
name: &str,
system_prompt: &str,
) -> openfang_types::agent::AgentId {
let manifest_str = format!(
r#"
name = "{name}"
version = "0.1.0"
description = "Workflow test agent: {name}"
author = "test"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
system_prompt = "{system_prompt}"
[capabilities]
memory_read = ["*"]
memory_write = ["self.*"]
"#
);
let manifest: AgentManifest = toml::from_str(&manifest_str).unwrap();
kernel.spawn_agent(manifest).expect("Agent should spawn")
}
// ---------------------------------------------------------------------------
// Kernel-level workflow wiring tests (no LLM needed)
// ---------------------------------------------------------------------------
/// Test that workflow registration and agent resolution work at the kernel level.
#[tokio::test]
async fn test_workflow_register_and_resolve() {
let config = test_config("ollama", "test-model", "OLLAMA_API_KEY");
let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot");
let kernel = Arc::new(kernel);
// Spawn agents
let manifest: AgentManifest = toml::from_str(
r#"
name = "agent-alpha"
version = "0.1.0"
description = "Alpha"
author = "test"
module = "builtin:chat"
[model]
provider = "ollama"
model = "test"
system_prompt = "Alpha."
[capabilities]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
)
.unwrap();
let alpha_id = kernel.spawn_agent(manifest).unwrap();
let manifest2: AgentManifest = toml::from_str(
r#"
name = "agent-beta"
version = "0.1.0"
description = "Beta"
author = "test"
module = "builtin:chat"
[model]
provider = "ollama"
model = "test"
system_prompt = "Beta."
[capabilities]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
)
.unwrap();
let beta_id = kernel.spawn_agent(manifest2).unwrap();
// Create a 2-step workflow referencing agents by name
let workflow = Workflow {
id: WorkflowId::new(),
name: "alpha-beta-pipeline".to_string(),
description: "Tests agent resolution by name".to_string(),
steps: vec![
WorkflowStep {
name: "step-alpha".to_string(),
agent: StepAgent::ByName {
name: "agent-alpha".to_string(),
},
prompt_template: "Analyze: {{input}}".to_string(),
mode: StepMode::Sequential,
timeout_secs: 30,
error_mode: ErrorMode::Fail,
output_var: Some("alpha_out".to_string()),
},
WorkflowStep {
name: "step-beta".to_string(),
agent: StepAgent::ByName {
name: "agent-beta".to_string(),
},
prompt_template: "Summarize: {{input}} (alpha said: {{alpha_out}})".to_string(),
mode: StepMode::Sequential,
timeout_secs: 30,
error_mode: ErrorMode::Fail,
output_var: None,
},
],
created_at: chrono::Utc::now(),
};
let wf_id = kernel.register_workflow(workflow).await;
// Verify workflow is registered
let workflows = kernel.workflows.list_workflows().await;
assert_eq!(workflows.len(), 1);
assert_eq!(workflows[0].name, "alpha-beta-pipeline");
// Verify agents can be found by name
let alpha = kernel.registry.find_by_name("agent-alpha");
assert!(alpha.is_some());
assert_eq!(alpha.unwrap().id, alpha_id);
let beta = kernel.registry.find_by_name("agent-beta");
assert!(beta.is_some());
assert_eq!(beta.unwrap().id, beta_id);
// Verify workflow run can be created
let run_id = kernel
.workflows
.create_run(wf_id, "test input".to_string())
.await;
assert!(run_id.is_some());
let run = kernel.workflows.get_run(run_id.unwrap()).await.unwrap();
assert_eq!(run.input, "test input");
kernel.shutdown();
}
/// Test workflow with agent referenced by ID.
#[tokio::test]
async fn test_workflow_agent_by_id() {
let config = test_config("ollama", "test-model", "OLLAMA_API_KEY");
let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot");
let manifest: AgentManifest = toml::from_str(
r#"
name = "id-agent"
version = "0.1.0"
description = "Test"
author = "test"
module = "builtin:chat"
[model]
provider = "ollama"
model = "test"
system_prompt = "Test."
[capabilities]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
)
.unwrap();
let agent_id = kernel.spawn_agent(manifest).unwrap();
let workflow = Workflow {
id: WorkflowId::new(),
name: "by-id-test".to_string(),
description: "".to_string(),
steps: vec![WorkflowStep {
name: "step1".to_string(),
agent: StepAgent::ById {
id: agent_id.to_string(),
},
prompt_template: "{{input}}".to_string(),
mode: StepMode::Sequential,
timeout_secs: 30,
error_mode: ErrorMode::Fail,
output_var: None,
}],
created_at: chrono::Utc::now(),
};
let wf_id = kernel.register_workflow(workflow).await;
// Can create run (agent resolution happens at execute time)
let run_id = kernel
.workflows
.create_run(wf_id, "hello".to_string())
.await;
assert!(run_id.is_some());
kernel.shutdown();
}
/// Test trigger registration and listing at kernel level.
#[tokio::test]
async fn test_trigger_registration_with_kernel() {
use openfang_kernel::triggers::TriggerPattern;
let config = test_config("ollama", "test-model", "OLLAMA_API_KEY");
let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot");
let manifest: AgentManifest = toml::from_str(
r#"
name = "trigger-agent"
version = "0.1.0"
description = "Trigger test"
author = "test"
module = "builtin:chat"
[model]
provider = "ollama"
model = "test"
system_prompt = "Test."
[capabilities]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
)
.unwrap();
let agent_id = kernel.spawn_agent(manifest).unwrap();
// Register triggers
let t1 = kernel
.register_trigger(
agent_id,
TriggerPattern::Lifecycle,
"Lifecycle event: {{event}}".to_string(),
0,
)
.unwrap();
let t2 = kernel
.register_trigger(
agent_id,
TriggerPattern::SystemKeyword {
keyword: "deploy".to_string(),
},
"Deploy event: {{event}}".to_string(),
5,
)
.unwrap();
// List all triggers
let all = kernel.list_triggers(None);
assert_eq!(all.len(), 2);
// List triggers for specific agent
let agent_triggers = kernel.list_triggers(Some(agent_id));
assert_eq!(agent_triggers.len(), 2);
// Remove one
assert!(kernel.remove_trigger(t1));
let remaining = kernel.list_triggers(None);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].id, t2);
kernel.shutdown();
}
// ---------------------------------------------------------------------------
// Full E2E with real LLM (skip if no GROQ_API_KEY)
// ---------------------------------------------------------------------------
/// End-to-end: boot kernel → spawn 2 agents → create 2-step workflow →
/// run it through the real Groq LLM → verify output flows from step 1 to step 2.
#[tokio::test]
async fn test_workflow_e2e_with_groq() {
if std::env::var("GROQ_API_KEY").is_err() {
eprintln!("GROQ_API_KEY not set, skipping E2E workflow test");
return;
}
let config = test_config("groq", "llama-3.3-70b-versatile", "GROQ_API_KEY");
let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot");
let kernel = Arc::new(kernel);
kernel.set_self_handle();
// Spawn two agents with distinct roles
let _analyst_id = spawn_test_agent(
&kernel,
"wf-analyst",
"You are an analyst. When given text, respond with exactly: ANALYSIS: followed by a one-sentence analysis.",
);
let _writer_id = spawn_test_agent(
&kernel,
"wf-writer",
"You are a writer. When given text, respond with exactly: SUMMARY: followed by a one-sentence summary.",
);
// Create a 2-step pipeline: analyst → writer
let workflow = Workflow {
id: WorkflowId::new(),
name: "analyst-writer-pipeline".to_string(),
description: "E2E integration test workflow".to_string(),
steps: vec![
WorkflowStep {
name: "analyze".to_string(),
agent: StepAgent::ByName {
name: "wf-analyst".to_string(),
},
prompt_template: "Analyze the following: {{input}}".to_string(),
mode: StepMode::Sequential,
timeout_secs: 60,
error_mode: ErrorMode::Fail,
output_var: None,
},
WorkflowStep {
name: "summarize".to_string(),
agent: StepAgent::ByName {
name: "wf-writer".to_string(),
},
prompt_template: "Summarize this analysis: {{input}}".to_string(),
mode: StepMode::Sequential,
timeout_secs: 60,
error_mode: ErrorMode::Fail,
output_var: None,
},
],
created_at: chrono::Utc::now(),
};
let wf_id = kernel.register_workflow(workflow).await;
// Run the workflow
let result = kernel
.run_workflow(
wf_id,
"The Rust programming language is growing rapidly.".to_string(),
)
.await;
assert!(
result.is_ok(),
"Workflow should complete: {:?}",
result.err()
);
let (run_id, output) = result.unwrap();
println!("\n=== WORKFLOW OUTPUT ===");
println!("{output}");
println!("======================\n");
assert!(!output.is_empty(), "Workflow output should not be empty");
// Verify the workflow run record
let run = kernel.workflows.get_run(run_id).await.unwrap();
assert!(matches!(
run.state,
openfang_kernel::workflow::WorkflowRunState::Completed
));
assert_eq!(run.step_results.len(), 2);
assert_eq!(run.step_results[0].step_name, "analyze");
assert_eq!(run.step_results[1].step_name, "summarize");
// Both steps should have used tokens
assert!(run.step_results[0].input_tokens > 0);
assert!(run.step_results[0].output_tokens > 0);
assert!(run.step_results[1].input_tokens > 0);
assert!(run.step_results[1].output_tokens > 0);
// List runs
let runs = kernel.workflows.list_runs(None).await;
assert_eq!(runs.len(), 1);
kernel.shutdown();
}