feat: prepare agen crates for publication
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
//! Reasoning history round-trip 統合テスト
|
||||
//!
|
||||
//! Engine のストリーム → history append → 次リクエスト送出までの
|
||||
//! ライフサイクルで `Item::Reasoning` が脱落せず保持されることを確認する。
|
||||
//!
|
||||
//! 検証点:
|
||||
//! - Anthropic 由来の thinking + signature が `Item::Reasoning::signature` として
|
||||
//! history に残る
|
||||
//! - OpenAI Responses 由来の reasoning text + summary + encrypted_content が
|
||||
//! `Item::Reasoning` の各フィールドに展開される
|
||||
//! - 直前の reasoning は次の outgoing request の `request.items` の先頭付近に
|
||||
//! 含まれる(assistant メッセージの先頭、Anthropic 仕様)
|
||||
|
||||
mod common;
|
||||
|
||||
use agen::Engine;
|
||||
use agen::Item;
|
||||
use agen::llm_client::event::{
|
||||
BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus,
|
||||
StatusEvent,
|
||||
};
|
||||
use common::MockLlmClient;
|
||||
|
||||
fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> {
|
||||
vec![
|
||||
Event::BlockStart(BlockStart {
|
||||
index: 100,
|
||||
block_type: BlockType::Thinking,
|
||||
metadata: BlockMetadata::Thinking,
|
||||
}),
|
||||
Event::BlockDelta(agen::llm_client::event::BlockDelta {
|
||||
index: 100,
|
||||
delta: agen::llm_client::event::DeltaContent::Thinking(text.into()),
|
||||
}),
|
||||
Event::BlockStop(BlockStop {
|
||||
index: 100,
|
||||
block_type: BlockType::Thinking,
|
||||
stop_reason: None,
|
||||
reasoning: Some(data),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// Anthropic 風: thinking ブロック → text → 終了 のシーケンス。
|
||||
/// Engine history に Reasoning(signature 付き) → assistant_message が並ぶ。
|
||||
#[tokio::test]
|
||||
async fn anthropic_thinking_round_trips_signature_into_history() {
|
||||
let mut events = reasoning_block(
|
||||
"let me think...",
|
||||
ReasoningBlockData {
|
||||
id: None,
|
||||
text: None,
|
||||
summary: Vec::new(),
|
||||
encrypted_content: None,
|
||||
signature: Some("SIG-OPUS".into()),
|
||||
},
|
||||
);
|
||||
events.extend([
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Here's the answer"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]);
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("question?").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
|
||||
let history = engine.history();
|
||||
// user / reasoning / assistant_message
|
||||
assert_eq!(history.len(), 3, "history: {history:?}");
|
||||
|
||||
assert!(matches!(history[0], Item::Message { .. }));
|
||||
match &history[1] {
|
||||
Item::Reasoning {
|
||||
text, signature, ..
|
||||
} => {
|
||||
assert_eq!(text, "let me think...");
|
||||
assert_eq!(signature.as_deref(), Some("SIG-OPUS"));
|
||||
}
|
||||
other => panic!("expected Reasoning, got {other:?}"),
|
||||
}
|
||||
assert_eq!(history[2].as_text(), Some("Here's the answer"));
|
||||
}
|
||||
|
||||
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
|
||||
/// `Item::Reasoning` のフィールドに展開されること。
|
||||
#[tokio::test]
|
||||
async fn openai_reasoning_round_trips_encrypted_and_summary() {
|
||||
let mut events = reasoning_block(
|
||||
"",
|
||||
ReasoningBlockData {
|
||||
id: Some("r1".into()),
|
||||
text: Some("inner reasoning".into()),
|
||||
summary: vec!["sum-A".into(), "sum-B".into()],
|
||||
encrypted_content: Some("ENC-OPAQUE".into()),
|
||||
signature: None,
|
||||
},
|
||||
);
|
||||
events.extend([
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "answer"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]);
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("q").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
|
||||
let history = engine.history();
|
||||
match &history[1] {
|
||||
Item::Reasoning {
|
||||
text,
|
||||
summary,
|
||||
encrypted_content,
|
||||
signature,
|
||||
id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(text, "inner reasoning");
|
||||
assert_eq!(summary, &vec!["sum-A".to_string(), "sum-B".to_string()]);
|
||||
assert_eq!(encrypted_content.as_deref(), Some("ENC-OPAQUE"));
|
||||
assert!(signature.is_none());
|
||||
assert_eq!(id.as_deref(), Some("r1"));
|
||||
}
|
||||
other => panic!("expected Reasoning, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reasoning は assistant ターン内で text/tool_call より先に並ぶこと(Anthropic
|
||||
/// が thinking を assistant メッセージの先頭に要求するため)。
|
||||
#[tokio::test]
|
||||
async fn reasoning_precedes_text_in_assistant_burst() {
|
||||
let mut events = vec![
|
||||
// text/tool_call とは独立に、reasoning block が中盤で完了しても、
|
||||
// history append 時には assistant items の先頭に置かれる。
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "intermediate"),
|
||||
Event::text_block_stop(0, None),
|
||||
];
|
||||
events.extend(reasoning_block(
|
||||
"after text",
|
||||
ReasoningBlockData {
|
||||
signature: Some("SIG".into()),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
events.push(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}));
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("q").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
|
||||
let history = engine.history();
|
||||
// user / reasoning(先頭) / assistant_message
|
||||
assert!(matches!(history[1], Item::Reasoning { .. }));
|
||||
assert_eq!(history[2].as_text(), Some("intermediate"));
|
||||
}
|
||||
|
||||
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
|
||||
/// 注入して run しても、次の outgoing request の `Request::items` にそのまま
|
||||
/// 載って LLM へ渡る(engine は items を改変しない契約)。
|
||||
#[tokio::test]
|
||||
async fn injected_reasoning_survives_into_outgoing_request() {
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||
|
||||
/// Request を 1 度だけキャプチャして空ストリームを返す client。
|
||||
#[derive(Clone)]
|
||||
struct CapturingClient {
|
||||
captured: Arc<Mutex<Option<Request>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for CapturingClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
*self.captured.lock().unwrap() = Some(request);
|
||||
let stream = futures::stream::iter(vec![Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}))]);
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
let captured = Arc::new(Mutex::new(None));
|
||||
let client = CapturingClient {
|
||||
captured: captured.clone(),
|
||||
};
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
// resume: 既存 history を流し込む
|
||||
engine.set_history(vec![
|
||||
Item::user_message("prior question"),
|
||||
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
|
||||
Item::assistant_message("prior answer"),
|
||||
]);
|
||||
|
||||
let _ = engine.run("follow up").await.expect("run ok");
|
||||
|
||||
let req = captured
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.expect("client should have received a request");
|
||||
// Reasoning item が outgoing items に保持されていること
|
||||
let mut found = false;
|
||||
for item in &req.items {
|
||||
if let Item::Reasoning {
|
||||
text, signature, ..
|
||||
} = item
|
||||
{
|
||||
assert_eq!(text, "prior thinking");
|
||||
assert_eq!(signature.as_deref(), Some("SIG-PRIOR"));
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found,
|
||||
"Reasoning item must survive into outgoing request items: {req:?}",
|
||||
req = req.items,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user