1use std::collections::VecDeque;
14use std::time::Instant;
15
16use zeph_core::channel::{ChannelError, ChannelMessage, ToolOutputEvent};
17
18#[derive(Debug, Clone)]
40pub struct CapturedResponse {
41 pub prompt_index: usize,
43 pub text: String,
45 pub elapsed: std::time::Duration,
48 pub input_tokens: u64,
50 pub output_tokens: u64,
52 pub context_window: u64,
54}
55
56pub struct BenchmarkChannel {
86 prompts: VecDeque<String>,
87 responses: Vec<CapturedResponse>,
88 tool_outputs: Vec<ToolOutputEvent>,
89 current_index: usize,
90 total: usize,
91 chunk_buffer: String,
93 chunk_start: Option<Instant>,
94 pending_input_tokens: u64,
96 pending_output_tokens: u64,
97 pending_context_window: u64,
98}
99
100impl BenchmarkChannel {
101 #[must_use]
116 pub fn new(prompts: Vec<String>) -> Self {
117 let total = prompts.len();
118 Self {
119 prompts: VecDeque::from(prompts),
120 responses: Vec::new(),
121 tool_outputs: Vec::new(),
122 current_index: 0,
123 total,
124 chunk_buffer: String::new(),
125 chunk_start: None,
126 pending_input_tokens: 0,
127 pending_output_tokens: 0,
128 pending_context_window: 0,
129 }
130 }
131
132 #[must_use]
161 pub fn from_turns(turns: Vec<crate::scenario::Turn>) -> Self {
162 use crate::scenario::Role;
163
164 let mut prompts = VecDeque::new();
165 let mut seeded_responses = Vec::new();
166 let mut prompt_index: usize = 0;
167
168 for turn in turns {
169 match turn.role {
170 Role::User => {
171 prompts.push_back(turn.content);
172 prompt_index += 1;
173 }
174 Role::Assistant => {
175 seeded_responses.push(CapturedResponse {
176 prompt_index: prompt_index.saturating_sub(1),
177 text: turn.content,
178 elapsed: std::time::Duration::ZERO,
179 input_tokens: 0,
180 output_tokens: 0,
181 context_window: 0,
182 });
183 }
184 }
185 }
186
187 let total = prompts.len();
188 Self {
189 prompts,
190 responses: seeded_responses,
191 tool_outputs: Vec::new(),
192 current_index: 0,
193 total,
194 chunk_buffer: String::new(),
195 chunk_start: None,
196 pending_input_tokens: 0,
197 pending_output_tokens: 0,
198 pending_context_window: 0,
199 }
200 }
201
202 #[must_use]
213 pub fn total(&self) -> usize {
214 self.total
215 }
216
217 #[must_use]
231 pub fn into_responses(self) -> Vec<CapturedResponse> {
232 self.responses
233 }
234
235 #[must_use]
246 pub fn responses(&self) -> &[CapturedResponse] {
247 &self.responses
248 }
249
250 #[must_use]
264 pub fn tool_outputs(&self) -> &[zeph_core::channel::ToolOutputEvent] {
265 &self.tool_outputs
266 }
267
268 fn flush_chunk_buffer(&mut self) {
269 if self.chunk_buffer.is_empty() {
270 return;
271 }
272 let elapsed = self
273 .chunk_start
274 .map_or(std::time::Duration::ZERO, |s| s.elapsed());
275 self.responses.push(CapturedResponse {
276 prompt_index: self.current_index.saturating_sub(1),
277 text: std::mem::take(&mut self.chunk_buffer),
278 elapsed,
279 input_tokens: self.pending_input_tokens,
280 output_tokens: self.pending_output_tokens,
281 context_window: self.pending_context_window,
282 });
283 self.chunk_start = None;
284 self.pending_input_tokens = 0;
285 self.pending_output_tokens = 0;
286 self.pending_context_window = 0;
287 }
288}
289
290impl zeph_core::channel::Channel for BenchmarkChannel {
291 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
292 match self.prompts.pop_front() {
293 Some(text) => {
294 self.current_index += 1;
295 Ok(Some(ChannelMessage {
296 text,
297 attachments: vec![],
298 is_guest_context: false,
299 is_from_bot: false,
300 owner_key: None,
301 }))
302 }
303 None => Ok(None),
304 }
305 }
306
307 fn supports_exit(&self) -> bool {
308 false
309 }
310
311 async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
312 self.responses.push(CapturedResponse {
313 prompt_index: self.current_index.saturating_sub(1),
314 text: text.to_owned(),
315 elapsed: std::time::Duration::ZERO,
316 input_tokens: self.pending_input_tokens,
317 output_tokens: self.pending_output_tokens,
318 context_window: self.pending_context_window,
319 });
320 self.pending_input_tokens = 0;
321 self.pending_output_tokens = 0;
322 self.pending_context_window = 0;
323 Ok(())
324 }
325
326 async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
327 if self.chunk_start.is_none() {
328 self.chunk_start = Some(Instant::now());
329 }
330 self.chunk_buffer.push_str(chunk);
331 Ok(())
332 }
333
334 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
335 self.flush_chunk_buffer();
336 Ok(())
337 }
338
339 async fn send_usage(
340 &mut self,
341 input_tokens: u64,
342 output_tokens: u64,
343 context_window: u64,
344 _cache_read_tokens: u64,
345 _cache_write_tokens: u64,
346 _cost_cents: f64,
347 ) -> Result<(), ChannelError> {
348 self.pending_input_tokens = input_tokens;
349 self.pending_output_tokens = output_tokens;
350 self.pending_context_window = context_window;
351 Ok(())
352 }
353
354 async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
355 self.tool_outputs.push(event);
356 Ok(())
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use std::assert_matches;
363 use zeph_core::channel::{
364 Channel, ElicitationField, ElicitationFieldType, ElicitationRequest, ElicitationResponse,
365 ToolOutputEvent,
366 };
367
368 use super::*;
369
370 #[tokio::test]
371 async fn recv_drains_queue_and_returns_none_when_empty() {
372 let mut ch = BenchmarkChannel::new(vec!["hello".into(), "world".into()]);
373 let msg1 = ch.recv().await.unwrap().unwrap();
374 assert_eq!(msg1.text, "hello");
375 let msg2 = ch.recv().await.unwrap().unwrap();
376 assert_eq!(msg2.text, "world");
377 let msg3 = ch.recv().await.unwrap();
378 assert!(msg3.is_none());
379 }
380
381 #[tokio::test]
382 async fn send_accumulates_response() {
383 let mut ch = BenchmarkChannel::new(vec!["prompt".into()]);
384 let _ = ch.recv().await.unwrap();
385 ch.send("response text").await.unwrap();
386 assert_eq!(ch.responses().len(), 1);
387 assert_eq!(ch.responses()[0].text, "response text");
388 }
389
390 #[tokio::test]
391 async fn confirm_returns_true() {
392 let mut ch = BenchmarkChannel::new(vec![]);
393 let result = ch.confirm("delete?").await.unwrap();
394 assert!(result);
395 }
396
397 #[tokio::test]
398 async fn elicit_returns_declined() {
399 let mut ch = BenchmarkChannel::new(vec![]);
400 let req = ElicitationRequest {
401 server_name: "test-server".into(),
402 message: "provide input".into(),
403 fields: vec![ElicitationField {
404 name: "field".into(),
405 description: None,
406 field_type: ElicitationFieldType::String,
407 required: true,
408 }],
409 };
410 let result = ch.elicit(req).await.unwrap();
411 assert_matches!(result, ElicitationResponse::Declined);
412 }
413
414 #[tokio::test]
415 async fn send_chunk_and_flush_captures_response() {
416 let mut ch = BenchmarkChannel::new(vec!["p".into()]);
417 let _ = ch.recv().await.unwrap();
418 ch.send_chunk("part1").await.unwrap();
419 ch.send_chunk(" part2").await.unwrap();
420 ch.flush_chunks().await.unwrap();
421 assert_eq!(ch.responses().len(), 1);
422 assert_eq!(ch.responses()[0].text, "part1 part2");
423 }
424
425 #[tokio::test]
426 async fn supports_exit_returns_false() {
427 let ch = BenchmarkChannel::new(vec![]);
428 assert!(!ch.supports_exit());
429 }
430
431 #[tokio::test]
432 async fn send_usage_captured_on_send() {
433 let mut ch = BenchmarkChannel::new(vec!["p".into()]);
434 let _ = ch.recv().await.unwrap();
435 ch.send_usage(10, 20, 128_000, 0, 0, 0.0).await.unwrap();
436 ch.send("answer").await.unwrap();
437 let r = &ch.responses()[0];
438 assert_eq!(r.input_tokens, 10);
439 assert_eq!(r.output_tokens, 20);
440 assert_eq!(r.context_window, 128_000);
441 }
442
443 #[tokio::test]
444 async fn send_tool_output_captured_separately_from_responses() {
445 let mut ch = BenchmarkChannel::new(vec!["p".into()]);
446 let _ = ch.recv().await.unwrap();
447 ch.send_tool_output(ToolOutputEvent {
448 tool_name: "bash".into(),
449 display: "some tool output".into(),
450 diff: None,
451 filter_stats: None,
452 kept_lines: None,
453 locations: None,
454 tool_call_id: "tc-1".into(),
455 terminal_id: None,
456 is_error: false,
457 parent_tool_use_id: None,
458 raw_response: None,
459 started_at: None,
460 })
461 .await
462 .unwrap();
463 assert_eq!(ch.responses().len(), 0);
465 assert_eq!(ch.tool_outputs().len(), 1);
467 assert_eq!(ch.tool_outputs()[0].tool_name, "bash");
468 }
469
470 #[test]
471 fn from_turns_splits_user_and_assistant() {
472 use crate::scenario::{Role, Turn};
473
474 let turns = vec![
475 Turn {
476 role: Role::User,
477 content: "Q1".into(),
478 },
479 Turn {
480 role: Role::Assistant,
481 content: "A1".into(),
482 },
483 Turn {
484 role: Role::User,
485 content: "Q2".into(),
486 },
487 ];
488 let ch = BenchmarkChannel::from_turns(turns);
489 assert_eq!(ch.total(), 2);
490 assert_eq!(ch.responses().len(), 1);
491 assert_eq!(ch.responses()[0].text, "A1");
492 }
493
494 #[test]
495 fn from_turns_user_only() {
496 use crate::scenario::{Role, Turn};
497
498 let turns = vec![Turn {
499 role: Role::User,
500 content: "Q".into(),
501 }];
502 let ch = BenchmarkChannel::from_turns(turns);
503 assert_eq!(ch.total(), 1);
504 assert!(ch.responses().is_empty());
505 }
506}