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 std::assert_matches;
362 use zeph_core::channel::{
363 Channel, ElicitationField, ElicitationFieldType, ElicitationRequest, ElicitationResponse,
364 ToolOutputEvent,
365 };
366
367 use super::*;
368
369 #[tokio::test]
370 async fn recv_drains_queue_and_returns_none_when_empty() {
371 let mut ch = BenchmarkChannel::new(vec!["hello".into(), "world".into()]);
372 let msg1 = ch.recv().await.unwrap().unwrap();
373 assert_eq!(msg1.text, "hello");
374 let msg2 = ch.recv().await.unwrap().unwrap();
375 assert_eq!(msg2.text, "world");
376 let msg3 = ch.recv().await.unwrap();
377 assert!(msg3.is_none());
378 }
379
380 #[tokio::test]
381 async fn send_accumulates_response() {
382 let mut ch = BenchmarkChannel::new(vec!["prompt".into()]);
383 let _ = ch.recv().await.unwrap();
384 ch.send("response text").await.unwrap();
385 assert_eq!(ch.responses().len(), 1);
386 assert_eq!(ch.responses()[0].text, "response text");
387 }
388
389 #[tokio::test]
390 async fn confirm_returns_true() {
391 let mut ch = BenchmarkChannel::new(vec![]);
392 let result = ch.confirm("delete?").await.unwrap();
393 assert!(result);
394 }
395
396 #[tokio::test]
397 async fn elicit_returns_declined() {
398 let mut ch = BenchmarkChannel::new(vec![]);
399 let req = ElicitationRequest {
400 server_name: "test-server".into(),
401 message: "provide input".into(),
402 fields: vec![ElicitationField {
403 name: "field".into(),
404 description: None,
405 field_type: ElicitationFieldType::String,
406 required: true,
407 }],
408 };
409 let result = ch.elicit(req).await.unwrap();
410 assert_matches!(result, ElicitationResponse::Declined);
411 }
412
413 #[tokio::test]
414 async fn send_chunk_and_flush_captures_response() {
415 let mut ch = BenchmarkChannel::new(vec!["p".into()]);
416 let _ = ch.recv().await.unwrap();
417 ch.send_chunk("part1").await.unwrap();
418 ch.send_chunk(" part2").await.unwrap();
419 ch.flush_chunks().await.unwrap();
420 assert_eq!(ch.responses().len(), 1);
421 assert_eq!(ch.responses()[0].text, "part1 part2");
422 }
423
424 #[tokio::test]
425 async fn supports_exit_returns_false() {
426 let ch = BenchmarkChannel::new(vec![]);
427 assert!(!ch.supports_exit());
428 }
429
430 #[tokio::test]
431 async fn send_usage_captured_on_send() {
432 let mut ch = BenchmarkChannel::new(vec!["p".into()]);
433 let _ = ch.recv().await.unwrap();
434 ch.send_usage(10, 20, 128_000, 0, 0, 0.0).await.unwrap();
435 ch.send("answer").await.unwrap();
436 let r = &ch.responses()[0];
437 assert_eq!(r.input_tokens, 10);
438 assert_eq!(r.output_tokens, 20);
439 assert_eq!(r.context_window, 128_000);
440 }
441
442 #[tokio::test]
443 async fn send_tool_output_captured_separately_from_responses() {
444 let mut ch = BenchmarkChannel::new(vec!["p".into()]);
445 let _ = ch.recv().await.unwrap();
446 ch.send_tool_output(ToolOutputEvent {
447 tool_name: "bash".into(),
448 display: "some tool output".into(),
449 diff: None,
450 filter_stats: None,
451 kept_lines: None,
452 locations: None,
453 tool_call_id: "tc-1".into(),
454 terminal_id: None,
455 is_error: false,
456 parent_tool_use_id: None,
457 raw_response: None,
458 started_at: None,
459 })
460 .await
461 .unwrap();
462 assert_eq!(ch.responses().len(), 0);
464 assert_eq!(ch.tool_outputs().len(), 1);
466 assert_eq!(ch.tool_outputs()[0].tool_name, "bash");
467 }
468
469 #[test]
470 fn from_turns_splits_user_and_assistant() {
471 use crate::scenario::{Role, Turn};
472
473 let turns = vec![
474 Turn {
475 role: Role::User,
476 content: "Q1".into(),
477 },
478 Turn {
479 role: Role::Assistant,
480 content: "A1".into(),
481 },
482 Turn {
483 role: Role::User,
484 content: "Q2".into(),
485 },
486 ];
487 let ch = BenchmarkChannel::from_turns(turns);
488 assert_eq!(ch.total(), 2);
489 assert_eq!(ch.responses().len(), 1);
490 assert_eq!(ch.responses()[0].text, "A1");
491 }
492
493 #[test]
494 fn from_turns_user_only() {
495 use crate::scenario::{Role, Turn};
496
497 let turns = vec![Turn {
498 role: Role::User,
499 content: "Q".into(),
500 }];
501 let ch = BenchmarkChannel::from_turns(turns);
502 assert_eq!(ch.total(), 1);
503 assert!(ch.responses().is_empty());
504 }
505}