Files
optimclaw/tests/e2e_workspace_coverage.rs
37bba72397 test: add 29 E2E trace tests for issues #571-575 (#593)
* test: add 29 E2E trace tests for worker, threading, tools, workspace, and routines (#571-575)

Add comprehensive E2E test coverage across five test files:
- e2e_worker_coverage (7 tests): parallel tool calls, error feedback, unknown tools,
  invalid params, rate limiting, iteration limits, planning mode
- e2e_thread_scheduling (3 tests + 2 deferred): multi-turn state, undo/redo, concurrent dispatch
- e2e_builtin_tool_coverage (8 tests): time parse/diff/invalid, routine CRUD/history,
  job create/status/list/cancel, HTTP replay
- e2e_workspace_coverage (6 tests): chunked search, multi-doc search, hybrid search,
  directory tree, document lifecycle, identity in system prompt
- e2e_routine_heartbeat (5 tests): cron triggers, event matching, cooldown enforcement,
  heartbeat findings, empty checklist skip

Infrastructure: extend TestRig with database/workspace/trace_llm accessors, register
job and routine tools by default, add with_extra_tools() for custom stub tools.

Includes 24 JSON trace fixtures across worker/, threading/, tools/, and workspace/.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use 6-field cron format in routine_create_list fixture

The cron 0.13 crate accepts both 6 and 7 fields, but the routine_create
tool documents 6-field format. Align the fixture to match.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: eliminate vacuous passes and silently-skipped assertions in E2E tests

- job_create_status: replace job_status (needs dynamic UUID) with list_jobs,
  assert both succeed via completed() not just started()
- job_list_cancel: keep cancel_job but explicitly assert it fails with
  invalid canned job_id "latest", verify create_job + list_jobs succeed
- unknown_tool_name: add !is_empty() guard before .all() to prevent
  vacuous pass on empty iterator
- workspace tests: change `if let Some(ws)` to `.expect()` so assertions
  are never silently skipped when workspace/trace_llm is available

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add template substitution to TraceLlm for dynamic tool result forwarding

Add {{call_id.json_path}} template syntax to trace fixtures, enabling
tool results from one step to flow into subsequent steps' arguments.
TraceLlm extracts variables from Role::Tool messages (stripping the
safety layer's <tool_output> XML wrapper and unescaping entities) and
substitutes them in canned tool_call arguments before returning.

This fixes job_create_status and job_list_cancel tests to properly test
job_status and cancel_job with real dynamic UUIDs from create_job,
instead of using invalid canned IDs that silently failed.

Also adds tool result content assertions to job_create_status to verify
the actual tool output contains expected data (job_id, title).

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback on E2E tests

- undo_redo_cycle: assert exactly 3 turns instead of >= 2
- tool_error_feedback: use tempfile::tempdir() instead of hardcoded /tmp path,
  patch fixture path at runtime for CI portability
- worker_timeout → iteration_limit: rename to accurately describe what's tested
- post_plan_work_remaining → simple_echo_flow: rename, test doesn't exercise planning
- identity_in_system_prompt: seed IDENTITY.md before test, assert system prompt
  contains the seeded content instead of just checking Role::System exists

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: strengthen workspace E2E test assertions per PR review

- write_chunk_search: assert memory_search was called and returned
  payment/architecture-related results
- multi_document_search: assert memory_search was called for
  cross-document search
- hybrid_search_with_embeddings: assert both memory_write and
  memory_search were called to confirm write-then-search pipeline
- directory_tree: assert tree output contains expected alpha/beta
  project paths

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-06 08:12:56 +00:00

321 lines
11 KiB
Rust

//! E2E trace tests: workspace persistence (#574).
//!
//! Covers chunking, multi-document search, hybrid search, directory tree,
//! document lifecycle (write/read/overwrite), and identity in system prompt.
#[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: write_chunk_search
// -----------------------------------------------------------------------
#[tokio::test]
async fn write_chunk_search() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/workspace/write_chunk_search.json"
))
.expect("failed to load write_chunk_search.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("Write a long architecture document and search it")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
// Verify the document was persisted via workspace.
let ws = rig.workspace().expect("workspace must be available");
let doc = ws
.read("context/architecture.md")
.await
.expect("architecture.md should exist");
assert!(
doc.content.contains("Payment Service"),
"Document should contain 'Payment Service'"
);
assert!(
doc.content.len() > 1000,
"Document should be long (>1000 chars), got {}",
doc.content.len()
);
// Verify memory_search was called and returned relevant results.
let started = rig.tool_calls_started();
assert!(
started.contains(&"memory_search".to_string()),
"memory_search should be called: {started:?}"
);
let results = rig.tool_results();
let search_results: Vec<_> = results
.iter()
.filter(|(name, _)| name == "memory_search")
.collect();
assert!(!search_results.is_empty(), "Expected memory_search results");
assert!(
search_results
.iter()
.any(|(_, preview)| preview.contains("Payment Service")
|| preview.contains("payment")
|| preview.contains("architecture")),
"memory_search should return results related to payment/architecture: {search_results:?}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 2: multi_document_search
// -----------------------------------------------------------------------
#[tokio::test]
async fn multi_document_search() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/workspace/multi_doc_search.json"
))
.expect("failed to load multi_doc_search.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("Write three docs and search across them")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
// Verify all three documents were written.
let ws = rig.workspace().expect("workspace must be available");
let frontend = ws.read("context/frontend.md").await;
let backend = ws.read("context/backend.md").await;
let devops = ws.read("context/devops.md").await;
assert!(frontend.is_ok(), "frontend.md should exist");
assert!(backend.is_ok(), "backend.md should exist");
assert!(devops.is_ok(), "devops.md should exist");
// Verify cross-document memory_search was called.
let started = rig.tool_calls_started();
assert!(
started.contains(&"memory_search".to_string()),
"memory_search should be called in multi_document_search: {started:?}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 3: hybrid_search_with_embeddings
// -----------------------------------------------------------------------
#[tokio::test]
async fn hybrid_search_with_embeddings() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/workspace/hybrid_search.json"
))
.expect("failed to load hybrid_search.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("Write and semantically search for ML content")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
// Verify both memory_write and memory_search were used.
// Without a real embedding provider the FTS path handles keyword matches;
// we assert both tools ran to confirm the write-then-search pipeline.
let started = rig.tool_calls_started();
assert!(
started.contains(&"memory_write".to_string()),
"memory_write should be called: {started:?}"
);
assert!(
started.contains(&"memory_search".to_string()),
"memory_search should be called: {started:?}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 4: directory_tree
// -----------------------------------------------------------------------
#[tokio::test]
async fn directory_tree() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/workspace/directory_tree.json"
))
.expect("failed to load directory_tree.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("Write files in a hierarchy and show the tree")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
// Verify tree tool was called.
let started = rig.tool_calls_started();
assert!(
started.contains(&"memory_tree".to_string()),
"memory_tree should be called: {started:?}"
);
// Verify the tree result contains the expected directory hierarchy.
let results = rig.tool_results();
let tree_results: Vec<_> = results
.iter()
.filter(|(name, _)| name == "memory_tree")
.collect();
assert!(!tree_results.is_empty(), "Expected memory_tree results");
let tree_output: String = tree_results
.iter()
.map(|(_, preview)| preview.as_str())
.collect();
assert!(
tree_output.contains("alpha") || tree_output.contains("Alpha"),
"memory_tree output should contain 'alpha' project, got: {tree_output:?}"
);
assert!(
tree_output.contains("beta") || tree_output.contains("Beta"),
"memory_tree output should contain 'beta' project, got: {tree_output:?}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 5: document_lifecycle
// -----------------------------------------------------------------------
#[tokio::test]
async fn document_lifecycle() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/workspace/doc_lifecycle.json"
))
.expect("failed to load doc_lifecycle.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("Write, read, overwrite, and read a document")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
// Verify the document has the updated content.
let ws = rig.workspace().expect("workspace must be available");
let doc = ws
.read("context/lifecycle.md")
.await
.expect("lifecycle.md should exist");
assert!(
doc.content.contains("Version 2"),
"Document should contain 'Version 2', got: {:?}",
doc.content
);
// memory_write and memory_read should each be called twice.
let started = rig.tool_calls_started();
let write_count = started
.iter()
.filter(|n| n.as_str() == "memory_write")
.count();
let read_count = started
.iter()
.filter(|n| n.as_str() == "memory_read")
.count();
assert_eq!(write_count, 2, "Expected 2 memory_write calls");
assert_eq!(read_count, 2, "Expected 2 memory_read calls");
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 6: identity_in_system_prompt
// -----------------------------------------------------------------------
#[tokio::test]
async fn identity_in_system_prompt() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/workspace/identity_prompt.json"
))
.expect("failed to load identity_prompt.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
// Seed an IDENTITY.md so the system prompt has real content to inject.
let ws = rig.workspace().expect("workspace must be available");
ws.write(
"IDENTITY.md",
"I am TestBot, a helpful testing assistant created for E2E verification.",
)
.await
.expect("write IDENTITY.md");
rig.send_message("Who are you?").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
// Verify the TraceLlm captured requests include a system message
// with the seeded identity content.
let trace_llm = rig.trace_llm().expect("trace_llm must be available");
let captured = trace_llm.captured_requests();
assert!(
!captured.is_empty(),
"Expected at least one captured request"
);
let first_request = &captured[0];
let system_msg = first_request
.iter()
.find(|msg| matches!(msg.role, ironclaw::llm::Role::System));
assert!(
system_msg.is_some(),
"Expected a system message in the first request"
);
assert!(
system_msg.unwrap().content.contains("TestBot"),
"System prompt should contain seeded identity 'TestBot', got: {:?}",
&system_msg.unwrap().content[..200.min(system_msg.unwrap().content.len())]
);
rig.shutdown();
}
}