feat: Implement Worker for LLM turn management/tool call/hooks

This commit is contained in:
2026-01-06 20:38:08 +09:00
parent a4e2795e56
commit e82e0a3ed9
13 changed files with 1330 additions and 71 deletions
+240
View File
@@ -0,0 +1,240 @@
//! テスト用共通ユーティリティ
//!
//! MockLlmClient、イベントレコーダー・プレイヤーを提供する
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::Path;
use std::pin::Pin;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use futures::Stream;
use serde::{Deserialize, Serialize};
use worker::llm_client::{ClientError, LlmClient, Request};
use worker_types::Event;
// =============================================================================
// Recorded Event Types
// =============================================================================
/// 記録されたSSEイベント
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedEvent {
/// イベント受信からの経過時間 (ミリ秒)
pub elapsed_ms: u64,
/// SSEイベントタイプ
pub event_type: String,
/// SSEイベントデータ
pub data: String,
}
/// セッションメタデータ
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMetadata {
/// 記録開始タイムスタンプ (Unix epoch秒)
pub timestamp: u64,
/// モデル名
pub model: String,
/// リクエストの説明
pub description: String,
}
// =============================================================================
// Event Recorder
// =============================================================================
/// SSEイベントレコーダー
///
/// 実際のAPIレスポンスを記録し、後でテストに使用できるようにする
#[allow(dead_code)]
pub struct EventRecorder {
start_time: Instant,
events: Vec<RecordedEvent>,
metadata: SessionMetadata,
}
#[allow(dead_code)]
impl EventRecorder {
/// 新しいレコーダーを作成
pub fn new(model: impl Into<String>, description: impl Into<String>) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Self {
start_time: Instant::now(),
events: Vec::new(),
metadata: SessionMetadata {
timestamp,
model: model.into(),
description: description.into(),
},
}
}
/// イベントを記録
pub fn record(&mut self, event_type: &str, data: &str) {
let elapsed = self.start_time.elapsed();
self.events.push(RecordedEvent {
elapsed_ms: elapsed.as_millis() as u64,
event_type: event_type.to_string(),
data: data.to_string(),
});
}
/// 記録をファイルに保存
///
/// フォーマット: JSONL (1行目: metadata, 2行目以降: events)
pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
// メタデータを書き込み
let metadata_json = serde_json::to_string(&self.metadata)?;
writeln!(writer, "{}", metadata_json)?;
// イベントを書き込み
for event in &self.events {
let event_json = serde_json::to_string(event)?;
writeln!(writer, "{}", event_json)?;
}
writer.flush()?;
Ok(())
}
/// 記録されたイベント数を取得
pub fn event_count(&self) -> usize {
self.events.len()
}
}
// =============================================================================
// Event Player
// =============================================================================
/// SSEイベントプレイヤー
///
/// 記録されたイベントを読み込み、テストで使用する
#[allow(dead_code)]
pub struct EventPlayer {
metadata: SessionMetadata,
events: Vec<RecordedEvent>,
current_index: usize,
}
#[allow(dead_code)]
impl EventPlayer {
/// ファイルから読み込み
pub fn load(path: impl AsRef<Path>) -> std::io::Result<Self> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
// メタデータを読み込み
let metadata_line = lines
.next()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "Empty file"))??;
let metadata: SessionMetadata = serde_json::from_str(&metadata_line)?;
// イベントを読み込み
let mut events = Vec::new();
for line in lines {
let line = line?;
if !line.is_empty() {
let event: RecordedEvent = serde_json::from_str(&line)?;
events.push(event);
}
}
Ok(Self {
metadata,
events,
current_index: 0,
})
}
/// メタデータを取得
pub fn metadata(&self) -> &SessionMetadata {
&self.metadata
}
/// 全イベントを取得
pub fn events(&self) -> &[RecordedEvent] {
&self.events
}
/// イベント数を取得
pub fn event_count(&self) -> usize {
self.events.len()
}
/// 次のイベントを取得(Iterator的に使用)
pub fn next_event(&mut self) -> Option<&RecordedEvent> {
if self.current_index < self.events.len() {
let event = &self.events[self.current_index];
self.current_index += 1;
Some(event)
} else {
None
}
}
/// インデックスをリセット
pub fn reset(&mut self) {
self.current_index = 0;
}
/// 全イベントをworker_types::Eventとしてパースして取得
pub fn parse_events(&self) -> Vec<Event> {
self.events
.iter()
.filter_map(|recorded| serde_json::from_str(&recorded.data).ok())
.collect()
}
}
// =============================================================================
// MockLlmClient
// =============================================================================
/// テスト用のモックLLMクライアント
///
/// 事前に定義されたイベントシーケンスをストリームとして返す。
/// fixtureファイルからロードすることも、直接イベントを渡すこともできる。
pub struct MockLlmClient {
events: Vec<Event>,
}
impl MockLlmClient {
/// イベントリストから直接作成
pub fn new(events: Vec<Event>) -> Self {
Self { events }
}
/// fixtureファイルからロード
pub fn from_fixture(path: impl AsRef<Path>) -> std::io::Result<Self> {
let player = EventPlayer::load(path)?;
let events = player.parse_events();
Ok(Self { events })
}
/// 保持しているイベント数を取得
pub fn event_count(&self) -> usize {
self.events.len()
}
}
#[async_trait]
impl LlmClient for MockLlmClient {
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
let events = self.events.clone();
let stream = futures::stream::iter(events.into_iter().map(Ok));
Ok(Box::pin(stream))
}
}
+16
View File
@@ -0,0 +1,16 @@
{"timestamp":1767692881,"model":"claude-sonnet-4-20250514","description":"Tool call response"}
{"elapsed_ms":1783,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":409,\"output_tokens\":3,\"total_tokens\":412,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
{"elapsed_ms":1783,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"Text\",\"metadata\":\"Text\"}}"}
{"elapsed_ms":1783,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"I'll check\"}}}"}
{"elapsed_ms":1883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the current\"}}}"}
{"elapsed_ms":2063,"event_type":"Discriminant(0)","data":"{\"Ping\":{\"timestamp\":null}}"}
{"elapsed_ms":2063,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" weather in Tokyo for you using\"}}}"}
{"elapsed_ms":2124,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the get_weather tool.\"}}}"}
{"elapsed_ms":2252,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":null}}"}
{"elapsed_ms":2253,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":1,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"toolu_011Hg5wju1LGL7F65HyfE6bM\",\"name\":\"get_weather\"}}}}"}
{"elapsed_ms":2253,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"\"}}}"}
{"elapsed_ms":2306,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"{\\\"city\\\": \\\"Tokyo\"}}}"}
{"elapsed_ms":2451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"\\\"}\"}}}"}
{"elapsed_ms":2451,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":1,\"block_type\":\"Text\",\"stop_reason\":null}}"}
{"elapsed_ms":2464,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":409,\"output_tokens\":71,\"total_tokens\":480,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
{"elapsed_ms":2470,"event_type":"Discriminant(2)","data":"{\"Status\":{\"status\":\"Completed\"}}"}
+243
View File
@@ -0,0 +1,243 @@
//! Workerフィクスチャベースの統合テスト
//!
//! 記録されたAPIレスポンスを使ってWorkerの動作をテストする。
//! APIキー不要でローカルで実行可能。
mod common;
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use async_trait::async_trait;
use common::MockLlmClient;
use worker::{Worker, WorkerConfig};
use worker_types::{Tool, ToolError};
/// フィクスチャディレクトリのパス
fn fixtures_dir() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
/// シンプルなテスト用ツール
#[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)
}
}
#[async_trait]
impl Tool for MockWeatherTool {
fn name(&self) -> &str {
"get_weather"
}
fn description(&self) -> &str {
"Get the current weather for a city"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name"
}
},
"required": ["city"]
})
}
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 worker_types::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 messages = vec![worker_types::Message::user("Hello")];
let result = worker.run(messages).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);
// 設定: ツール実行後はターン終了(ループしない)
worker = worker.config(WorkerConfig { max_turns: 1 });
// メッセージを送信
let messages = vec![worker_types::Message::user("What's the weather in Tokyo?")];
let _result = worker.run(messages).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 worker_types::{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 messages = vec![worker_types::Message::user("Greet me")];
let result = worker.run(messages).await;
assert!(result.is_ok(), "Worker should complete successfully");
}
/// ToolCallCollectorがToolUseブロックイベントから正しくToolCallを収集することを確認
///
/// Timelineにイベントをディスパッチし、ToolCallCollectorが
/// id, name, inputJSON)を正しく抽出できることを検証する。
#[tokio::test]
async fn test_tool_call_collector_integration() {
use worker::ToolCallCollector;
use worker::Timeline;
use worker_types::Event;
// 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 {
timeline.dispatch(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");
}