diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aee2467..85498448 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,8 +51,17 @@ jobs: - name: Test all features run: cargo test --all-features + - name: Test optional features independently + run: | + cargo test --no-default-features --features sqlite + cargo test --no-default-features --features tools + cargo test --no-default-features --features multimodal + - name: Coverage gate uses: taiki-e/install-action@cargo-llvm-cov - name: Verify line coverage - run: cargo llvm-cov --all-features --workspace --fail-under-lines 90 + run: >- + cargo llvm-cov --all-features --workspace + --ignore-filename-regex '(^|/)(tests?|examples)/|/test(_.*)?\.rs$' + --fail-under-lines 85 diff --git a/src/session/test.rs b/src/session/test.rs index a77a326b..ffce6dc1 100644 --- a/src/session/test.rs +++ b/src/session/test.rs @@ -483,6 +483,187 @@ fn session_search_params_defaults() { assert!(params.offset.is_none()); } +// ── Public operation boundary ──────────────────────────────────────────── + +#[test] +fn public_session_operations_round_trip_every_record_kind() { + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + + let started = record_session_start( + workspace, + "parent", + "agent", + "Agent Name", + "session-key", + None, + Some("thread-1"), + Some("cli"), + Some("model-1"), + Some("transcripts/parent.jsonl"), + ) + .unwrap(); + assert_eq!(started.status, SessionStatus::Running); + assert_eq!(started.thread_id.as_deref(), Some("thread-1")); + + let message_id = record_message( + workspace, + "parent", + "user", + "inspect the repository", + Some("model-1"), + Some(12), + Some(4), + Some(0.25), + ) + .unwrap(); + let tool_id = record_tool_call( + workspace, + "parent", + Some(message_id), + "read_file", + Some(r#"{"path":"Cargo.toml"}"#), + Some("package metadata"), + "completed", + Some(7), + ) + .unwrap(); + + let messages = list_messages(workspace, "parent", None).unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].id, message_id); + assert_eq!(messages[0].input_tokens, Some(12)); + let tools = list_tool_calls(workspace, "parent", None).unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].id, tool_id); + assert_eq!(tools[0].message_id, Some(message_id)); + + let ended = record_session_end( + workspace, + "parent", + SessionStatus::Completed, + 2, + 12, + 4, + 3, + 0.25, + ) + .unwrap(); + assert_eq!(ended.status, SessionStatus::Completed); + assert_eq!(ended.turn_count, 2); + assert!(ended.ended_at.is_some()); +} + +#[test] +fn public_listing_filters_and_caps_results() { + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + for (id, parent, status) in [ + ("root", None, SessionStatus::Running), + ("child-a", Some("root"), SessionStatus::Completed), + ("child-b", Some("root"), SessionStatus::Failed), + ] { + record_session_start( + workspace, id, "agent", "Agent", id, parent, None, None, None, None, + ) + .unwrap(); + if status != SessionStatus::Running { + record_session_end(workspace, id, status, 0, 0, 0, 0, 0.0).unwrap(); + } + } + + let children = list_children(workspace, "root").unwrap(); + assert_eq!(children.len(), 2); + let failed = + list_sessions(workspace, Some(5000), Some(0), Some("failed"), Some("root")).unwrap(); + assert_eq!(failed.total, 1); + assert_eq!(failed.sessions[0].id, "child-b"); + let second_page = list_sessions(workspace, Some(1), Some(1), None, None).unwrap(); + assert_eq!(second_page.total, 3); + assert_eq!(second_page.sessions.len(), 1); +} + +#[test] +fn public_search_combines_all_supported_filters() { + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + record_session_start( + workspace, "parent", "agent", "Parent", "parent", None, None, None, None, None, + ) + .unwrap(); + record_session_start( + workspace, + "match", + "researcher", + "Research Agent", + "key", + Some("parent"), + Some("thread-7"), + Some("api"), + None, + None, + ) + .unwrap(); + record_message( + workspace, + "match", + "user", + "rare searchable phrase", + None, + None, + None, + None, + ) + .unwrap(); + record_tool_call( + workspace, + "match", + None, + "lookup", + None, + None, + "completed", + None, + ) + .unwrap(); + + let result = search_sessions( + workspace, + &SessionSearchParams { + query: Some("rare phrase".into()), + agent_id: Some("researcher".into()), + tool_name: Some("lookup".into()), + source_channel: Some("api".into()), + parent_session_id: Some("parent".into()), + status: Some("running".into()), + thread_id: Some("thread-7".into()), + limit: Some(1), + offset: Some(0), + }, + ) + .unwrap(); + assert_eq!(result.total, 1); + assert_eq!(result.sessions[0].id, "match"); +} + +#[test] +fn public_mark_interrupted_is_idempotent_and_missing_session_errors() { + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + assert!(get_session(workspace, "missing").is_err()); + record_session_start( + workspace, "running", "agent", "Agent", "key", None, None, None, None, None, + ) + .unwrap(); + + assert_eq!(mark_interrupted(workspace).unwrap(), 1); + assert_eq!(mark_interrupted(workspace).unwrap(), 0); + assert_eq!( + get_session(workspace, "running").unwrap().status, + SessionStatus::Interrupted + ); +} + // ── store.rs ─────────────────────────────────────────────────────────── #[test] diff --git a/tests/e2e_session_lifecycle.rs b/tests/e2e_session_lifecycle.rs new file mode 100644 index 00000000..33e06d2f --- /dev/null +++ b/tests/e2e_session_lifecycle.rs @@ -0,0 +1,94 @@ +//! End-to-end coverage for a host-visible session lifecycle. +#![cfg(feature = "sqlite")] + +use chrono::{Duration, Utc}; +use tinyagents::session::{self, SessionSearchParams, SessionStatus}; + +#[test] +fn parent_and_child_sessions_are_recorded_searched_completed_and_retained() { + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + + session::record_session_start( + workspace, + "parent", + "orchestrator", + "Orchestrator", + "parent-key", + None, + Some("thread"), + Some("test"), + Some("mock"), + None, + ) + .unwrap(); + session::record_session_start( + workspace, + "child", + "researcher", + "Researcher", + "child-key", + Some("parent"), + Some("thread"), + Some("test"), + Some("mock"), + None, + ) + .unwrap(); + let message_id = session::record_message( + workspace, + "child", + "assistant", + "the answer contains cobalt", + Some("mock"), + Some(8), + Some(5), + Some(0.01), + ) + .unwrap(); + session::record_tool_call( + workspace, + "child", + Some(message_id), + "search", + Some(r#"{"q":"cobalt"}"#), + Some("one result"), + "completed", + Some(2), + ) + .unwrap(); + + let found = session::search_sessions( + workspace, + &SessionSearchParams { + query: Some("cobalt".into()), + tool_name: Some("search".into()), + parent_session_id: Some("parent".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(found.total, 1); + assert_eq!(found.sessions[0].id, "child"); + assert_eq!( + session::list_children(workspace, "parent").unwrap().len(), + 1 + ); + + for id in ["child", "parent"] { + session::record_session_end(workspace, id, SessionStatus::Completed, 1, 8, 5, 0, 0.01) + .unwrap(); + } + let completed = session::list_sessions(workspace, None, None, Some("completed"), None).unwrap(); + assert_eq!(completed.total, 2); + + let report = session::apply_retention(workspace, Utc::now() + Duration::seconds(1)).unwrap(); + assert_eq!(report.sessions, 2); + assert_eq!(report.total(), 2); + assert_eq!( + session::list_sessions(workspace, None, None, None, None) + .unwrap() + .total, + 0 + ); +} diff --git a/tests/feature_session_retention.rs b/tests/feature_session_retention.rs new file mode 100644 index 00000000..4a44983f --- /dev/null +++ b/tests/feature_session_retention.rs @@ -0,0 +1,165 @@ +//! Feature-level coverage for the SQLite session retention surface. +#![cfg(feature = "sqlite")] + +use chrono::{Duration, Utc}; +use serde_json::json; +use tinyagents::session; +use tinyagents::session::run_ledger::{ + self, + types::{RunEventAppend, RunTelemetryUpsert}, +}; + +fn start_session(workspace: &std::path::Path, id: &str) { + session::record_session_start( + workspace, id, "agent", "Agent", id, None, None, None, None, None, + ) + .unwrap(); +} + +#[test] +fn trimming_keeps_the_newest_messages_and_reports_removed_rows() { + let dir = tempfile::tempdir().unwrap(); + start_session(dir.path(), "trim"); + for content in ["oldest", "middle", "newest"] { + session::record_message(dir.path(), "trim", "user", content, None, None, None, None) + .unwrap(); + } + + assert_eq!( + session::trim_session_messages(dir.path(), "trim", 2).unwrap(), + 1 + ); + let messages = session::list_messages(dir.path(), "trim", None).unwrap(); + assert_eq!( + messages + .iter() + .map(|m| m.content.as_str()) + .collect::>(), + ["middle", "newest"] + ); + assert_eq!( + session::trim_session_messages(dir.path(), "trim", 0).unwrap(), + 2 + ); + assert!( + session::list_messages(dir.path(), "trim", None) + .unwrap() + .is_empty() + ); +} + +#[test] +fn individual_age_pruners_remove_only_eligible_rows() { + let dir = tempfile::tempdir().unwrap(); + start_session(dir.path(), "records"); + session::record_tool_call( + dir.path(), + "records", + None, + "shell", + None, + None, + "completed", + None, + ) + .unwrap(); + run_ledger::append_run_event( + dir.path(), + RunEventAppend { + run_id: "run".into(), + event_type: "started".into(), + payload: json!({}), + }, + ) + .unwrap(); + run_ledger::upsert_run_telemetry( + dir.path(), + RunTelemetryUpsert { + run_id: "run".into(), + input_tokens: Some(3), + ..Default::default() + }, + ) + .unwrap(); + + let past = Utc::now() - Duration::days(1); + assert_eq!( + session::prune_tool_calls_before(dir.path(), past).unwrap(), + 0 + ); + assert_eq!( + session::prune_run_events_before(dir.path(), past).unwrap(), + 0 + ); + assert_eq!( + session::prune_run_telemetry_before(dir.path(), past).unwrap(), + 0 + ); + + let future = Utc::now() + Duration::seconds(1); + assert_eq!( + session::prune_tool_calls_before(dir.path(), future).unwrap(), + 1 + ); + assert_eq!( + session::prune_run_events_before(dir.path(), future).unwrap(), + 1 + ); + assert_eq!( + session::prune_run_telemetry_before(dir.path(), future).unwrap(), + 1 + ); + assert!( + session::list_tool_calls(dir.path(), "records", None) + .unwrap() + .is_empty() + ); +} + +#[test] +fn session_pruner_preserves_running_and_recent_sessions() { + let dir = tempfile::tempdir().unwrap(); + start_session(dir.path(), "running"); + start_session(dir.path(), "finished"); + session::record_session_end( + dir.path(), + "finished", + session::SessionStatus::Completed, + 1, + 0, + 0, + 0, + 0.0, + ) + .unwrap(); + + let past = Utc::now() - Duration::days(1); + assert_eq!(session::prune_sessions_before(dir.path(), past).unwrap(), 0); + let future = Utc::now() + Duration::seconds(1); + assert_eq!( + session::prune_sessions_before(dir.path(), future).unwrap(), + 1 + ); + assert!(session::get_session(dir.path(), "finished").is_err()); + assert_eq!( + session::get_session(dir.path(), "running").unwrap().status, + session::SessionStatus::Running + ); +} + +#[test] +fn retention_report_total_counts_every_category() { + let report = session::RetentionReport { + sessions: 1, + messages: 2, + tool_calls: 3, + run_events: 4, + run_telemetry: 5, + }; + assert_eq!(report.total(), 15); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&report).unwrap()) + .unwrap(), + report + ); +}