mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* Add event-triggered routines and workflow skill templates * fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 <[email protected]> * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 <[email protected]> * fix: address PR review feedback for event_emit security and quality Security fixes: - Require approval (UnlessAutoApproved) for event_emit, matching routine_fire - Enable sanitization on event_emit payload (external JSON reaches LLM) - Remove user_id parameter from event_emit to prevent IDOR — always use ctx.user_id Correctness fixes: - Rename source → event_source in event_emit for consistency with routine_create - Use json_value_as_filter_string for filter parsing (handles numbers/booleans) - Case-insensitive matching for event source and event_type - Add debug logging for missing filter keys in payload - Fix skill_install_routine_webhook_sim test missing .with_skills() - Fix schema_validator test for event_emit payload properties Code quality: - Move EventEmitTool struct/impl after RoutineHistoryTool (fix split layout) - Deduplicate routine_to_info into RoutineInfo::from_routine in types.rs - Add test section headers in e2e_routine_heartbeat.rs - Clarify event_emit description to specify system_event routines only Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 <[email protected]> * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> * fix: make routine_system_event_emit test create routine before emitting - Add routine_create step to trace fixture so event_emit has a matching routine to fire - Assert fired_routines > 0, not just key presence (Copilot review) - Add .with_auto_approve_tools(true) since event_emit now requires approval Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: renumber test headers after system_event test insertion Test 4 was duplicated (routine_cooldown and heartbeat_findings). Renumber heartbeat_findings to Test 5 and heartbeat_empty_skip to Test 6. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: merge staging and add missing RoutineEngine args in test RoutineEngine::new on staging requires `tools` and `safety` params. Update system_event_trigger_matches_and_filters test to pass them. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address new Copilot review comments - Add .with_auto_approve_tools(true) to skill_install_routine_webhook_sim test so event_emit doesn't block on approval - Fix module-level doc comment for event_emit to specify system_event trigger [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: deduplicate json_value_as_string helper Remove private `json_value_as_string` from routine_engine.rs and use the identical public `json_value_as_filter_string` from routine.rs, eliminating divergence risk. (Copilot review) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Henry Park <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]>
456 lines
16 KiB
Rust
456 lines
16 KiB
Rust
//! E2E trace tests: builtin tool coverage (#573).
|
|
//!
|
|
//! Covers time (parse, diff, invalid), routine (create, list, update, delete,
|
|
//! history), job (create, status, list, cancel), and HTTP replay.
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod support;
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod tests {
|
|
use std::time::Duration;
|
|
|
|
use crate::support::test_rig::TestRigBuilder;
|
|
use crate::support::trace_llm::LlmTrace;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 1: time_parse_and_diff
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn time_parse_and_diff() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/time_parse_diff.json"
|
|
))
|
|
.expect("failed to load time_parse_diff.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.with_skills()
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Parse a time and compute a diff").await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// Time tool should have been called twice (parse + diff).
|
|
let started = rig.tool_calls_started();
|
|
let time_count = started.iter().filter(|n| n.as_str() == "time").count();
|
|
assert!(
|
|
time_count >= 2,
|
|
"Expected >= 2 time tool calls, got {time_count}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 2: time_parse_invalid
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn time_parse_invalid() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/time_parse_invalid.json"
|
|
))
|
|
.expect("failed to load time_parse_invalid.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.with_skills()
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Parse an invalid timestamp").await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// The time tool call should have failed (invalid timestamp).
|
|
let completed = rig.tool_calls_completed();
|
|
let time_results: Vec<_> = completed
|
|
.iter()
|
|
.filter(|(name, _)| name == "time")
|
|
.collect();
|
|
assert!(!time_results.is_empty(), "Expected time tool to be called");
|
|
assert!(
|
|
time_results.iter().any(|(_, ok)| !ok),
|
|
"Expected at least one failed time call: {time_results:?}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 3: routine_create_list
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_create_list() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_create_list.json"
|
|
))
|
|
.expect("failed to load routine_create_list.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.with_skills()
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a daily routine and list all routines")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// Both routine_create and routine_list should have succeeded.
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "routine_create" && *ok),
|
|
"routine_create should succeed: {completed:?}"
|
|
);
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "routine_list" && *ok),
|
|
"routine_list should succeed: {completed:?}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 4: routine_update_delete
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_update_delete() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_update_delete.json"
|
|
))
|
|
.expect("failed to load routine_update_delete.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create, update, and delete a routine")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let started = rig.tool_calls_started();
|
|
assert!(
|
|
started.contains(&"routine_create".to_string()),
|
|
"routine_create not started"
|
|
);
|
|
assert!(
|
|
started.contains(&"routine_update".to_string()),
|
|
"routine_update not started"
|
|
);
|
|
assert!(
|
|
started.contains(&"routine_delete".to_string()),
|
|
"routine_delete not started"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 5: routine_history
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_history() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_history.json"
|
|
))
|
|
.expect("failed to load routine_history.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a routine and check its history")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let started = rig.tool_calls_started();
|
|
assert!(
|
|
started.contains(&"routine_create".to_string()),
|
|
"routine_create missing"
|
|
);
|
|
assert!(
|
|
started.contains(&"routine_history".to_string()),
|
|
"routine_history missing"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 6: routine_system_event_emit
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_system_event_emit() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_system_event_emit.json"
|
|
))
|
|
.expect("failed to load routine_system_event_emit.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a system-event routine and emit an event")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "event_emit" && *ok),
|
|
"event_emit should succeed: {completed:?}"
|
|
);
|
|
|
|
let results = rig.tool_results();
|
|
let emit_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "event_emit")
|
|
.expect("event_emit result missing");
|
|
assert!(
|
|
emit_result.1.contains("fired_routines"),
|
|
"event_emit should report fired routine count: {:?}",
|
|
emit_result.1
|
|
);
|
|
// Verify at least one routine actually fired (not just that the key exists).
|
|
let emit_json: serde_json::Value =
|
|
serde_json::from_str(&emit_result.1).expect("event_emit result should be valid JSON");
|
|
assert!(
|
|
emit_json["fired_routines"].as_u64().unwrap_or(0) > 0,
|
|
"event_emit should have fired at least one routine: {:?}",
|
|
emit_result.1
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 7: skill_install_routine_webhook_sim
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn skill_install_routine_webhook_sim() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json"
|
|
))
|
|
.expect("failed to load skill_install_routine_webhook_sim.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_skills()
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Install the workflow skill template and simulate a webhook routine run")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(20)).await;
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, _)| n == "skill_install"),
|
|
"skill_install should be called: {completed:?}"
|
|
);
|
|
for tool in &["routine_create", "event_emit", "routine_history"] {
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == tool && *ok),
|
|
"{tool} should succeed: {completed:?}"
|
|
);
|
|
}
|
|
|
|
let results = rig.tool_results();
|
|
let emit_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "event_emit")
|
|
.expect("event_emit result missing");
|
|
assert!(
|
|
emit_result.1.contains("fired_routines"),
|
|
"event_emit should include fired_routines: {:?}",
|
|
emit_result.1
|
|
);
|
|
|
|
let _history_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "routine_history")
|
|
.expect("routine_history result missing");
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 8: job_create_status
|
|
// -----------------------------------------------------------------------
|
|
// Uses {{call_cj_1.job_id}} template to forward the dynamic UUID from
|
|
// create_job's result into job_status's arguments.
|
|
|
|
#[tokio::test]
|
|
async fn job_create_status() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/job_create_status.json"
|
|
))
|
|
.expect("failed to load job_create_status.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a job and check its status").await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// Both tools should have succeeded.
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "create_job" && *ok),
|
|
"create_job should succeed: {completed:?}"
|
|
);
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "job_status" && *ok),
|
|
"job_status should succeed: {completed:?}"
|
|
);
|
|
|
|
// Verify tool results contain expected content.
|
|
let results = rig.tool_results();
|
|
let create_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "create_job")
|
|
.expect("create_job result missing");
|
|
assert!(
|
|
create_result.1.contains("job_id"),
|
|
"create_job should return a job_id: {:?}",
|
|
create_result.1
|
|
);
|
|
assert!(
|
|
create_result.1.contains("in_progress"),
|
|
"create_job should dispatch through the scheduler, not stay pending: {:?}",
|
|
create_result.1
|
|
);
|
|
assert!(
|
|
!create_result.1.contains("scheduler unavailable"),
|
|
"create_job should not fall back to the unscheduled path: {:?}",
|
|
create_result.1
|
|
);
|
|
let status_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "job_status")
|
|
.expect("job_status result missing");
|
|
assert!(
|
|
status_result.1.contains("Test analysis job"),
|
|
"job_status should return the job title: {:?}",
|
|
status_result.1
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 9: job_list_cancel
|
|
// -----------------------------------------------------------------------
|
|
// Uses {{call_cj_lc.job_id}} template to forward the dynamic UUID from
|
|
// create_job into cancel_job.
|
|
|
|
#[tokio::test]
|
|
async fn job_list_cancel() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/job_list_cancel.json"
|
|
))
|
|
.expect("failed to load job_list_cancel.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a job, list jobs, then cancel it")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// All three tools should have succeeded.
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "create_job" && *ok),
|
|
"create_job should succeed: {completed:?}"
|
|
);
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "list_jobs" && *ok),
|
|
"list_jobs should succeed: {completed:?}"
|
|
);
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "cancel_job" && *ok),
|
|
"cancel_job should succeed: {completed:?}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 8: http_get_with_replay
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn http_get_with_replay() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/http_get_replay.json"
|
|
))
|
|
.expect("failed to load http_get_replay.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Make an http GET request").await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// HTTP tool should have succeeded with the replayed exchange.
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "http" && *ok),
|
|
"http tool should succeed: {completed:?}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
}
|