llm-worker 0.2.1

A library for building autonomous LLM-powered systems
Documentation
//! Workerフィクスチャベースの統合テスト
//!
//! 記録されたAPIレスポンスを使ってWorkerの動作をテストする。
//! APIキー不要でローカルで実行可能。

mod common;

use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use async_trait::async_trait;
use common::MockLlmClient;
use llm_worker::Worker;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta};

/// フィクスチャディレクトリのパス
fn fixtures_dir() -> std::path::PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/anthropic")
}

/// シンプルなテスト用ツール
#[derive(Clone)]
struct MockWeatherTool {
    call_count: Arc<AtomicUsize>,
}

impl MockWeatherTool {
    fn new() -> Self {
        Self {
            call_count: Arc::new(AtomicUsize::new(0)),
        }
    }

    fn get_call_count(&self) -> usize {
        self.call_count.load(Ordering::SeqCst)
    }

    fn definition(&self) -> ToolDefinition {
        let tool = self.clone();
        Arc::new(move || {
            let meta = ToolMeta::new("get_weather")
                .description("Get the current weather for a city")
                .input_schema(serde_json::json!({
                    "type": "object",
                    "properties": {
                        "city": {
                            "type": "string",
                            "description": "The city name"
                        }
                    },
                    "required": ["city"]
                }));
            (meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
        })
    }
}

#[async_trait]
impl Tool for MockWeatherTool {
    async fn execute(&self, input_json: &str) -> Result<String, ToolError> {
        self.call_count.fetch_add(1, Ordering::SeqCst);

        // 入力をパース
        let input: serde_json::Value = serde_json::from_str(input_json)
            .map_err(|e| ToolError::InvalidArgument(e.to_string()))?;

        let city = input["city"].as_str().unwrap_or("Unknown");

        // モックのレスポンスを返す
        Ok(format!("Weather in {}: Sunny, 22°C", city))
    }
}

// =============================================================================
// Basic Fixture Tests
// =============================================================================

/// MockLlmClientがJSONLフィクスチャファイルから正しくイベントをロードできることを確認
///
/// 既存のanthropic_*.jsonlファイルを使用し、イベントがパース・ロードされることを検証する。
#[test]
fn test_mock_client_from_fixture() {
    // 既存のフィクスチャをロード
    let fixture_path = fixtures_dir().join("anthropic_1767624445.jsonl");
    if !fixture_path.exists() {
        println!("Fixture not found, skipping test");
        return;
    }

    let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
    assert!(client.event_count() > 0, "Should have loaded events");
    println!("Loaded {} events from fixture", client.event_count());
}

/// MockLlmClientが直接指定されたイベントリストで正しく動作することを確認
///
/// fixtureファイルを使わず、プログラムでイベントを構築してクライアントを作成する。
#[test]
fn test_mock_client_from_events() {
    use llm_worker::llm_client::event::Event;

    // 直接イベントを指定
    let events = vec![
        Event::text_block_start(0),
        Event::text_delta(0, "Hello!"),
        Event::text_block_stop(0, None),
    ];

    let client = MockLlmClient::new(events);
    assert_eq!(client.event_count(), 3);
}

// =============================================================================
// Worker Tests with Fixtures
// =============================================================================

/// Workerがシンプルなテキストレスポンスを正しく処理できることを確認
///
/// simple_text.jsonlフィクスチャを使用し、ツール呼び出しなしのシナリオをテストする。
/// フィクスチャがない場合はスキップされる。
#[tokio::test]
async fn test_worker_simple_text_response() {
    let fixture_path = fixtures_dir().join("simple_text.jsonl");
    if !fixture_path.exists() {
        println!("Fixture not found: {:?}, skipping test", fixture_path);
        println!("Run: cargo run --example record_worker_test");
        return;
    }

    let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
    let mut worker = Worker::new(client);

    // シンプルなメッセージを送信
    let result = worker.run("Hello").await;

    assert!(result.is_ok(), "Worker should complete successfully");
}

/// Workerがツール呼び出しを含むレスポンスを正しく処理できることを確認
///
/// tool_call.jsonlフィクスチャを使用し、MockWeatherToolが呼び出されることをテストする。
/// max_turns=1に設定し、ツール実行後のループを防止。
#[tokio::test]
async fn test_worker_tool_call() {
    let fixture_path = fixtures_dir().join("tool_call.jsonl");
    if !fixture_path.exists() {
        println!("Fixture not found: {:?}, skipping test", fixture_path);
        println!("Run: cargo run --example record_worker_test");
        return;
    }

    let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
    let mut worker = Worker::new(client);

    // ツールを登録
    let weather_tool = MockWeatherTool::new();
    let tool_for_check = weather_tool.clone();
    worker.register_tool(weather_tool.definition()).unwrap();

    // メッセージを送信
    let _result = worker.run("What's the weather in Tokyo?").await;

    // ツールが呼び出されたことを確認
    // Note: max_turns=1なのでツール結果後のリクエストは送信されない
    let call_count = tool_for_check.get_call_count();
    println!("Tool was called {} times", call_count);

    // フィクスチャにToolUseが含まれていればツールが呼び出されるはず
    // ただしmax_turns=1なので1回で終了
}

/// fixtureファイルなしでWorkerが動作することを確認
///
/// プログラムでイベントシーケンスを構築し、MockLlmClientに渡してテストする。
/// テストの独立性を高め、外部ファイルへの依存を排除したい場合に有用。
#[tokio::test]
async fn test_worker_with_programmatic_events() {
    use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};

    // プログラムでイベントシーケンスを構築
    let events = vec![
        Event::text_block_start(0),
        Event::text_delta(0, "Hello, "),
        Event::text_delta(0, "World!"),
        Event::text_block_stop(0, None),
        Event::Status(StatusEvent {
            status: ResponseStatus::Completed,
        }),
    ];

    let client = MockLlmClient::new(events);
    let mut worker = Worker::new(client);

    let result = worker.run("Greet me").await;

    assert!(result.is_ok(), "Worker should complete successfully");
}

/// ToolCallCollectorがToolUseブロックイベントから正しくToolCallを収集することを確認
///
/// Timelineにイベントをディスパッチし、ToolCallCollectorが
/// id, name, input(JSON)を正しく抽出できることを検証する。
#[tokio::test]
async fn test_tool_call_collector_integration() {
    use llm_worker::llm_client::event::Event;
    use llm_worker::timeline::{Timeline, ToolCallCollector};

    // ToolUseブロックを含むイベントシーケンス
    let events = vec![
        Event::tool_use_start(0, "call_123", "get_weather"),
        Event::tool_input_delta(0, r#"{"city":"#),
        Event::tool_input_delta(0, r#""Tokyo"}"#),
        Event::tool_use_stop(0),
    ];

    let collector = ToolCallCollector::new();
    let mut timeline = Timeline::new();
    timeline.on_tool_use_block(collector.clone());

    // イベントをディスパッチ
    for event in &events {
        let timeline_event: llm_worker::timeline::event::Event = event.clone().into();
        timeline.dispatch(&timeline_event);
    }

    // 収集されたToolCallを確認
    let calls = collector.take_collected();
    assert_eq!(calls.len(), 1, "Should collect one tool call");
    assert_eq!(calls[0].name, "get_weather");
    assert_eq!(calls[0].id, "call_123");
    assert_eq!(calls[0].input["city"], "Tokyo");
}