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 }))
301 }
302 None => Ok(None),
303 }
304 }
305
306 fn supports_exit(&self) -> bool {
307 false
308 }
309
310 async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
311 self.responses.push(CapturedResponse {
312 prompt_index: self.current_index.saturating_sub(1),
313 text: text.to_owned(),
314 elapsed: std::time::Duration::ZERO,
315 input_tokens: self.pending_input_tokens,
316 output_tokens: self.pending_output_tokens,
317 context_window: self.pending_context_window,
318 });
319 self.pending_input_tokens = 0;
320 self.pending_output_tokens = 0;
321 self.pending_context_window = 0;
322 Ok(())
323 }
324
325 async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
326 if self.chunk_start.is_none() {
327 self.chunk_start = Some(Instant::now());
328 }
329 self.chunk_buffer.push_str(chunk);
330 Ok(())
331 }
332
333 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
334 self.flush_chunk_buffer();
335 Ok(())
336 }
337
338 async fn send_usage(
339 &mut self,
340 input_tokens: u64,
341 output_tokens: u64,
342 context_window: u64,
343 _cache_read_tokens: u64,
344 _cache_write_tokens: u64,
345 _cost_cents: f64,
346 ) -> Result<(), ChannelError> {
347 self.pending_input_tokens = input_tokens;
348 self.pending_output_tokens = output_tokens;
349 self.pending_context_window = context_window;
350 Ok(())
351 }
352
353 async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
354 self.tool_outputs.push(event);
355 Ok(())
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use zeph_core::channel::{
362 Channel, ElicitationField, ElicitationFieldType, ElicitationRequest, ElicitationResponse,
363 ToolOutputEvent,
364 };
365
366 use super::*;
367
368 #[tokio::test]
369 async fn recv_drains_queue_and_returns_none_when_empty() {
370 let mut ch = BenchmarkChannel::new(vec!["hello".into(), "world".into()]);
371 let msg1 = ch.recv().await.unwrap().unwrap();
372 assert_eq!(msg1.text, "hello");
373 let msg2 = ch.recv().await.unwrap().unwrap();
374 assert_eq!(msg2.text, "world");
375 let msg3 = ch.recv().await.unwrap();
376 assert!(msg3.is_none());
377 }
378
379 #[tokio::test]
380 async fn send_accumulates_response() {
381 let mut ch = BenchmarkChannel::new(vec!["prompt".into()]);
382 let _ = ch.recv().await.unwrap();
383 ch.send("response text").await.unwrap();
384 assert_eq!(ch.responses().len(), 1);
385 assert_eq!(ch.responses()[0].text, "response text");
386 }
387
388 #[tokio::test]
389 async fn confirm_returns_true() {
390 let mut ch = BenchmarkChannel::new(vec![]);
391 let result = ch.confirm("delete?").await.unwrap();
392 assert!(result);
393 }
394
395 #[tokio::test]
396 async fn elicit_returns_declined() {
397 let mut ch = BenchmarkChannel::new(vec![]);
398 let req = ElicitationRequest {
399 server_name: "test-server".into(),
400 message: "provide input".into(),
401 fields: vec![ElicitationField {
402 name: "field".into(),
403 description: None,
404 field_type: ElicitationFieldType::String,
405 required: true,
406 }],
407 };
408 let result = ch.elicit(req).await.unwrap();
409 assert!(matches!(result, ElicitationResponse::Declined));
410 }
411
412 #[tokio::test]
413 async fn send_chunk_and_flush_captures_response() {
414 let mut ch = BenchmarkChannel::new(vec!["p".into()]);
415 let _ = ch.recv().await.unwrap();
416 ch.send_chunk("part1").await.unwrap();
417 ch.send_chunk(" part2").await.unwrap();
418 ch.flush_chunks().await.unwrap();
419 assert_eq!(ch.responses().len(), 1);
420 assert_eq!(ch.responses()[0].text, "part1 part2");
421 }
422
423 #[tokio::test]
424 async fn supports_exit_returns_false() {
425 let ch = BenchmarkChannel::new(vec![]);
426 assert!(!ch.supports_exit());
427 }
428
429 #[tokio::test]
430 async fn send_usage_captured_on_send() {
431 let mut ch = BenchmarkChannel::new(vec!["p".into()]);
432 let _ = ch.recv().await.unwrap();
433 ch.send_usage(10, 20, 128_000, 0, 0, 0.0).await.unwrap();
434 ch.send("answer").await.unwrap();
435 let r = &ch.responses()[0];
436 assert_eq!(r.input_tokens, 10);
437 assert_eq!(r.output_tokens, 20);
438 assert_eq!(r.context_window, 128_000);
439 }
440
441 #[tokio::test]
442 async fn send_tool_output_captured_separately_from_responses() {
443 let mut ch = BenchmarkChannel::new(vec!["p".into()]);
444 let _ = ch.recv().await.unwrap();
445 ch.send_tool_output(ToolOutputEvent {
446 tool_name: "bash".into(),
447 display: "some tool output".into(),
448 diff: None,
449 filter_stats: None,
450 kept_lines: None,
451 locations: None,
452 tool_call_id: "tc-1".into(),
453 terminal_id: None,
454 is_error: false,
455 parent_tool_use_id: None,
456 raw_response: None,
457 started_at: None,
458 })
459 .await
460 .unwrap();
461 assert_eq!(ch.responses().len(), 0);
463 assert_eq!(ch.tool_outputs().len(), 1);
465 assert_eq!(ch.tool_outputs()[0].tool_name, "bash");
466 }
467
468 #[test]
469 fn from_turns_splits_user_and_assistant() {
470 use crate::scenario::{Role, Turn};
471
472 let turns = vec![
473 Turn {
474 role: Role::User,
475 content: "Q1".into(),
476 },
477 Turn {
478 role: Role::Assistant,
479 content: "A1".into(),
480 },
481 Turn {
482 role: Role::User,
483 content: "Q2".into(),
484 },
485 ];
486 let ch = BenchmarkChannel::from_turns(turns);
487 assert_eq!(ch.total(), 2);
488 assert_eq!(ch.responses().len(), 1);
489 assert_eq!(ch.responses()[0].text, "A1");
490 }
491
492 #[test]
493 fn from_turns_user_only() {
494 use crate::scenario::{Role, Turn};
495
496 let turns = vec![Turn {
497 role: Role::User,
498 content: "Q".into(),
499 }];
500 let ch = BenchmarkChannel::from_turns(turns);
501 assert_eq!(ch.total(), 1);
502 assert!(ch.responses().is_empty());
503 }
504}