feat: Implement Worker for LLM turn management/tool call/hooks
This commit is contained in:
@@ -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
@@ -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\"}}"}
|
||||
@@ -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, input(JSON)を正しく抽出できることを検証する。
|
||||
#[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");
|
||||
}
|
||||
Reference in New Issue
Block a user