返回 DeepSeek-TUI-2026
eval.rs
根目录 / crates / tui / src / eval.rs
1 //! Offline evaluation harness for exercising representative tool loops.
2 //!
3 //! This module is intentionally self-contained so it can be wired into a CLI
4 //! command later without calling the network or any LLM endpoints.
5
6 use anyhow::{Context, Result, anyhow};
7 use ignore::WalkBuilder;
8 use regex::Regex;
9 use serde::{Deserialize, Serialize};
10 use std::collections::BTreeMap;
11 use std::fs;
12 use std::io::Write;
13 use std::path::{Path, PathBuf};
14 use std::process::Command;
15 use std::time::{Duration, Instant};
16 use tempfile::TempDir;
17
18 /// Representative tool steps covered by the evaluation harness.
19 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
20 pub enum ScenarioStepKind {
21 List,
22 Read,
23 Search,
24 Edit,
25 ApplyPatch,
26 ExecShell,
27 }
28
29 impl ScenarioStepKind {
30 /// Tool name associated with this step.
31 pub fn tool_name(self) -> &'static str {
32 match self {
33 ScenarioStepKind::List => "list_dir",
34 ScenarioStepKind::Read => "read_file",
35 ScenarioStepKind::Search => "search",
36 ScenarioStepKind::Edit => "edit_file",
37 ScenarioStepKind::ApplyPatch => "apply_patch",
38 ScenarioStepKind::ExecShell => "exec_shell",
39 }
40 }
41
42 /// Parse a step kind from CLI-friendly strings.
43 pub fn parse(value: &str) -> Option<Self> {
44 match value.trim().to_lowercase().as_str() {
45 "list" | "list_dir" => Some(Self::List),
46 "read" | "read_file" => Some(Self::Read),
47 "search" | "grep" | "grep_files" => Some(Self::Search),
48 "edit" | "edit_file" => Some(Self::Edit),
49 "patch" | "apply_patch" => Some(Self::ApplyPatch),
50 "shell" | "exec_shell" | "exec" => Some(Self::ExecShell),
51 _ => None,
52 }
53 }
54 }
55
56 /// Aggregate statistics for a single tool kind.
57 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
58 pub struct ToolStats {
59 pub invocations: usize,
60 pub errors: usize,
61 pub total_duration: Duration,
62 }
63
64 /// Top-level metrics produced by an evaluation run.
65 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66 pub struct EvalMetrics {
67 pub success: bool,
68 pub tool_errors: usize,
69 pub steps: usize,
70 pub duration: Duration,
71 pub per_tool: BTreeMap<ScenarioStepKind, ToolStats>,
72 }
73
74 /// One tool invocation recorded by the harness.
75 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76 pub struct EvalStep {
77 pub kind: ScenarioStepKind,
78 pub tool_name: &'static str,
79 pub success: bool,
80 pub duration: Duration,
81 pub error: Option<String>,
82 pub output: Option<String>,
83 }
84
85 /// Summary of the generated temporary workspace.
86 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87 pub struct WorkspaceSummary {
88 pub root: PathBuf,
89 pub file_count: usize,
90 pub files: Vec<PathBuf>,
91 }
92
93 /// Configuration for the offline evaluation harness.
94 #[derive(Debug, Clone)]
95 pub struct EvalHarnessConfig {
96 /// Human-readable scenario name for reporting.
97 pub scenario_name: String,
98 /// If set, the harness will intentionally fail this step to test metrics.
99 pub fail_step: Option<ScenarioStepKind>,
100 /// Shell command executed during the `exec_shell` step.
101 pub shell_command: String,
102 /// Token that must appear in shell output for validation.
103 pub shell_expect_token: String,
104 /// Maximum characters stored for step output summaries.
105 pub max_output_chars: usize,
106 /// When set, every step is appended as a JSON Lines fixture to a file
107 /// inside this directory. The fixture file is named after the scenario
108 /// (e.g. `offline-tool-loop.jsonl`). Each line follows the schema:
109 /// `{ "request": <step descriptor>, "response_events": [<events>] }`.
110 /// The mock LLM client (`crate::llm_client::mock`) can replay these
111 /// fixtures for deterministic offline tests. See
112 /// `crates/tui/tests/README.md` for the full record/replay flow.
113 pub record_dir: Option<PathBuf>,
114 }
115
116 impl Default for EvalHarnessConfig {
117 fn default() -> Self {
118 let shell_command = if cfg!(windows) {
119 "echo eval-harness".to_string()
120 } else {
121 "printf eval-harness".to_string()
122 };
123 Self {
124 scenario_name: "offline-tool-loop".to_string(),
125 fail_step: None,
126 shell_command,
127 shell_expect_token: "eval-harness".to_string(),
128 max_output_chars: 240,
129 record_dir: None,
130 }
131 }
132 }
133
134 /// Offline harness that exercises representative tool loops in a temp workspace.
135 #[derive(Debug, Clone)]
136 pub struct EvalHarness {
137 config: EvalHarnessConfig,
138 }
139
140 impl EvalHarness {
141 /// Create a new harness with the provided configuration.
142 pub fn new(config: EvalHarnessConfig) -> Self {
143 Self { config }
144 }
145
146 /// Execute the offline evaluation scenario and return detailed results.
147 pub fn run(&self) -> Result<EvalRun> {
148 let started_at = Instant::now();
149 let workspace = tempfile::Builder::new()
150 .prefix("deepseek-eval-")
151 .tempdir()
152 .context("failed to create evaluation workspace")?;
153
154 let seed = seed_workspace(workspace.path())?;
155
156 let mut steps = Vec::new();
157 let mut per_tool: BTreeMap<ScenarioStepKind, ToolStats> = BTreeMap::new();
158
159 let list_output = self.run_step(ScenarioStepKind::List, &mut steps, &mut per_tool, || {
160 let entries = list_dir(workspace.path())?;
161 Ok(entries.join(", "))
162 });
163
164 let _read_output = self.run_step(ScenarioStepKind::Read, &mut steps, &mut per_tool, || {
165 let path = if self.config.fail_step == Some(ScenarioStepKind::Read) {
166 workspace.path().join("missing.txt")
167 } else {
168 seed.notes_path.clone()
169 };
170 read_file(&path)
171 });
172
173 let search_output =
174 self.run_step(ScenarioStepKind::Search, &mut steps, &mut per_tool, || {
175 let root = if self.config.fail_step == Some(ScenarioStepKind::Search) {
176 workspace.path().join("missing-dir")
177 } else {
178 workspace.path().to_path_buf()
179 };
180 let result = search_files(&root, "offline")?;
181 Ok(format!("matches={}", result.matches.len()))
182 });
183
184 let edit_output = self.run_step(ScenarioStepKind::Edit, &mut steps, &mut per_tool, || {
185 let path = if self.config.fail_step == Some(ScenarioStepKind::Edit) {
186 workspace.path().join("missing.txt")
187 } else {
188 seed.notes_path.clone()
189 };
190 edit_file_append(&path, "edited = true")?;
191 Ok("appended line".to_string())
192 });
193
194 let patch_output = self.run_step(
195 ScenarioStepKind::ApplyPatch,
196 &mut steps,
197 &mut per_tool,
198 || {
199 let patch = if self.config.fail_step == Some(ScenarioStepKind::ApplyPatch) {
200 "*** Begin Patch\n*** Update File: notes.txt\n@@\n-THIS LINE DOES NOT EXIST\n+broken\n*** End Patch\n"
201 .to_string()
202 } else {
203 "*** Begin Patch\n*** Update File: notes.txt\n@@\n status = \"draft\"\n-todo: offline metrics\n+todo: offline metrics (patched)\n*** End Patch\n"
204 .to_string()
205 };
206 apply_patch(workspace.path(), &patch)?;
207 Ok("patch applied".to_string())
208 },
209 );
210
211 let shell_output = self.run_step(
212 ScenarioStepKind::ExecShell,
213 &mut steps,
214 &mut per_tool,
215 || {
216 let command = if self.config.fail_step == Some(ScenarioStepKind::ExecShell) {
217 "command_that_does_not_exist".to_string()
218 } else {
219 self.config.shell_command.clone()
220 };
221 exec_shell(workspace.path(), &command)
222 },
223 );
224
225 let duration = started_at.elapsed();
226
227 let workspace_summary = summarize_workspace(workspace.path(), list_output.as_deref())?;
228
229 let validation_success = validate_outputs(
230 workspace.path(),
231 &self.config.shell_expect_token,
232 search_output.as_deref(),
233 edit_output.as_deref(),
234 patch_output.as_deref(),
235 shell_output.as_deref(),
236 );
237
238 let tool_errors = steps.iter().filter(|s| !s.success).count();
239 let success = tool_errors == 0 && validation_success;
240
241 let metrics = EvalMetrics {
242 success,
243 tool_errors,
244 steps: steps.len(),
245 duration,
246 per_tool,
247 };
248
249 Ok(EvalRun {
250 scenario_name: self.config.scenario_name.clone(),
251 workspace,
252 workspace_summary,
253 metrics,
254 steps,
255 })
256 }
257
258 fn run_step<T, F>(
259 &self,
260 kind: ScenarioStepKind,
261 steps: &mut Vec<EvalStep>,
262 per_tool: &mut BTreeMap<ScenarioStepKind, ToolStats>,
263 f: F,
264 ) -> Option<T>
265 where
266 F: FnOnce() -> Result<T>,
267 T: ToString,
268 {
269 let started_at = Instant::now();
270 let result = f();
271 let duration = started_at.elapsed();
272
273 let stats = per_tool.entry(kind).or_default();
274 stats.invocations += 1;
275 stats.total_duration += duration;
276
277 match result {
278 Ok(value) => {
279 let output = truncate_output(&value.to_string(), self.config.max_output_chars);
280 steps.push(EvalStep {
281 kind,
282 tool_name: kind.tool_name(),
283 success: true,
284 duration,
285 error: None,
286 output: Some(output.clone()),
287 });
288 if let Some(dir) = self.config.record_dir.as_deref() {
289 let _ = record_fixture(
290 dir,
291 &self.config.scenario_name,
292 FixtureRecord::ok(kind, &output),
293 );
294 }
295 Some(value)
296 }
297 Err(err) => {
298 stats.errors += 1;
299 let err_str = err.to_string();
300 steps.push(EvalStep {
301 kind,
302 tool_name: kind.tool_name(),
303 success: false,
304 duration,
305 error: Some(err_str.clone()),
306 output: None,
307 });
308 if let Some(dir) = self.config.record_dir.as_deref() {
309 let _ = record_fixture(
310 dir,
311 &self.config.scenario_name,
312 FixtureRecord::err(kind, &err_str),
313 );
314 }
315 None
316 }
317 }
318 }
319 }
320
321 // === Fixture record/replay format ===========================================
322 //
323 // The `--record` flag writes one JSON object per line to a `.jsonl` file:
324 //
325 // { "request": { "step": "list_dir", "kind": "List" },
326 // "response_events": [{ "type": "ok", "output": "…" }] }
327 //
328 // The mock LLM client replays these fixtures via
329 // `MockLlmClient::push_message_response` (or the streaming variant) by mapping
330 // each `response_events` array onto a canned `Vec<StreamEvent>`.
331 //
332 // This format is intentionally minimal — additional fields (timing, model,
333 // usage) can be added without breaking older fixtures because each line is a
334 // self-contained JSON object.
335
336 /// Schema for one line of a `--record` JSONL fixture file.
337 #[derive(Debug, Clone, Serialize, Deserialize)]
338 pub struct FixtureRecord {
339 /// Step descriptor (`{ step, kind }`).
340 pub request: serde_json::Value,
341 /// One or more synthetic response events.
342 pub response_events: Vec<serde_json::Value>,
343 }
344
345 impl FixtureRecord {
346 fn ok(kind: ScenarioStepKind, output: &str) -> Self {
347 Self {
348 request: serde_json::json!({
349 "step": kind.tool_name(),
350 "kind": format!("{kind:?}"),
351 }),
352 response_events: vec![serde_json::json!({
353 "type": "ok",
354 "output": output,
355 })],
356 }
357 }
358
359 fn err(kind: ScenarioStepKind, error: &str) -> Self {
360 Self {
361 request: serde_json::json!({
362 "step": kind.tool_name(),
363 "kind": format!("{kind:?}"),
364 }),
365 response_events: vec![serde_json::json!({
366 "type": "error",
367 "error": error,
368 })],
369 }
370 }
371 }
372
373 /// Append one fixture record to `<dir>/<scenario>.jsonl` (creating dir + file
374 /// if missing). Best-effort: I/O errors are returned but generally ignored by
375 /// the harness so a recording failure does not mask the run's primary result.
376 pub fn record_fixture(dir: &Path, scenario_name: &str, record: FixtureRecord) -> Result<PathBuf> {
377 fs::create_dir_all(dir)
378 .with_context(|| format!("failed to create fixture dir: {}", dir.display()))?;
379 let safe_scenario = scenario_name
380 .chars()
381 .map(|c| {
382 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
383 c
384 } else {
385 '_'
386 }
387 })
388 .collect::<String>();
389 let path = dir.join(format!("{safe_scenario}.jsonl"));
390 let line = serde_json::to_string(&record).context("failed to serialize fixture record")?;
391
392 let mut file = fs::OpenOptions::new()
393 .create(true)
394 .append(true)
395 .open(&path)
396 .with_context(|| format!("failed to open fixture file: {}", path.display()))?;
397 writeln!(file, "{line}")
398 .with_context(|| format!("failed to write fixture line to {}", path.display()))?;
399 Ok(path)
400 }
401
402 impl Default for EvalHarness {
403 fn default() -> Self {
404 Self::new(EvalHarnessConfig::default())
405 }
406 }
407
408 /// Result of running the evaluation harness.
409 #[derive(Debug)]
410 pub struct EvalRun {
411 pub scenario_name: String,
412 workspace: TempDir,
413 pub workspace_summary: WorkspaceSummary,
414 pub metrics: EvalMetrics,
415 pub steps: Vec<EvalStep>,
416 }
417
418 impl EvalRun {
419 /// Get the root of the temporary workspace.
420 pub fn workspace_root(&self) -> &Path {
421 self.workspace.path()
422 }
423
424 /// Convert the run into a serializable report for CLI output.
425 pub fn to_report(&self) -> EvalReport {
426 EvalReport {
427 scenario_name: self.scenario_name.clone(),
428 workspace_root: self.workspace_root().to_path_buf(),
429 workspace_summary: self.workspace_summary.clone(),
430 metrics: self.metrics.clone(),
431 steps: self.steps.clone(),
432 }
433 }
434 }
435
436 /// Serializable report derived from an `EvalRun`.
437 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
438 pub struct EvalReport {
439 pub scenario_name: String,
440 pub workspace_root: PathBuf,
441 pub workspace_summary: WorkspaceSummary,
442 pub metrics: EvalMetrics,
443 pub steps: Vec<EvalStep>,
444 }
445
446 #[derive(Debug, Clone)]
447 struct SeedWorkspace {
448 notes_path: PathBuf,
449 }
450
451 fn seed_workspace(root: &Path) -> Result<SeedWorkspace> {
452 let src_dir = root.join("src");
453 fs::create_dir_all(&src_dir)
454 .with_context(|| format!("failed to create seed directory: {}", src_dir.display()))?;
455
456 let readme_path = root.join("README.md");
457 fs::write(
458 &readme_path,
459 "# Eval Harness Workspace\n\nThis workspace is offline.\n",
460 )
461 .with_context(|| format!("failed to write {}", readme_path.display()))?;
462
463 let notes_path = root.join("notes.txt");
464 fs::write(
465 &notes_path,
466 "# Eval Harness\nstatus = \"draft\"\ntodo: offline metrics\n",
467 )
468 .with_context(|| format!("failed to write {}", notes_path.display()))?;
469
470 let lib_path = src_dir.join("lib.rs");
471 fs::write(
472 &lib_path,
473 "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
474 )
475 .with_context(|| format!("failed to write {}", lib_path.display()))?;
476
477 Ok(SeedWorkspace { notes_path })
478 }
479
480 fn summarize_workspace(root: &Path, list_output: Option<&str>) -> Result<WorkspaceSummary> {
481 let mut files = Vec::new();
482
483 let walker = WalkBuilder::new(root)
484 .hidden(false)
485 .git_ignore(false)
486 .git_global(false)
487 .git_exclude(false)
488 .build();
489
490 for entry in walker {
491 let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
492 if entry.file_type().is_some_and(|t| t.is_file()) {
493 files.push(entry.into_path());
494 }
495 }
496
497 if files.is_empty()
498 && let Some(output) = list_output
499 && !output.trim().is_empty()
500 {
501 return Err(anyhow!(
502 "workspace appears empty after list_dir: {}",
503 output.trim()
504 ));
505 }
506
507 files.sort();
508
509 Ok(WorkspaceSummary {
510 root: root.to_path_buf(),
511 file_count: files.len(),
512 files,
513 })
514 }
515
516 fn validate_outputs(
517 root: &Path,
518 shell_expect_token: &str,
519 search_output: Option<&str>,
520 edit_output: Option<&str>,
521 patch_output: Option<&str>,
522 shell_output: Option<&str>,
523 ) -> bool {
524 let notes_path = root.join("notes.txt");
525 let notes = match fs::read_to_string(&notes_path) {
526 Ok(content) => content,
527 Err(_) => return false,
528 };
529
530 let search_ok = search_output.is_some_and(|s| s.contains("matches="));
531 let edit_ok = edit_output.is_some_and(|s| !s.is_empty()) && notes.contains("edited = true");
532 let patch_ok = patch_output.is_some_and(|s| !s.is_empty())
533 && notes.contains("todo: offline metrics (patched)");
534 let shell_ok = shell_output
535 .map(str::trim)
536 .is_some_and(|s| s.contains(shell_expect_token));
537
538 search_ok && edit_ok && patch_ok && shell_ok
539 }
540
541 fn list_dir(path: &Path) -> Result<Vec<String>> {
542 let mut entries = Vec::new();
543 let dir = fs::read_dir(path)
544 .with_context(|| format!("failed to read directory: {}", path.display()))?;
545
546 for entry in dir {
547 let entry = entry.with_context(|| format!("failed to list {}", path.display()))?;
548 entries.push(entry.file_name().to_string_lossy().to_string());
549 }
550
551 entries.sort();
552 Ok(entries)
553 }
554
555 fn read_file(path: &Path) -> Result<String> {
556 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
557 }
558
559 #[derive(Debug, Clone, PartialEq, Eq)]
560 struct SearchMatch {
561 path: PathBuf,
562 line: usize,
563 content: String,
564 }
565
566 #[derive(Debug, Clone, PartialEq, Eq)]
567 struct SearchResult {
568 matches: Vec<SearchMatch>,
569 }
570
571 fn search_files(root: &Path, pattern: &str) -> Result<SearchResult> {
572 if !root.exists() {
573 return Err(anyhow!("search root does not exist: {}", root.display()));
574 }
575
576 let regex = Regex::new(pattern).context("failed to compile search regex")?;
577 let mut matches = Vec::new();
578
579 let walker = WalkBuilder::new(root)
580 .hidden(false)
581 .git_ignore(false)
582 .git_global(false)
583 .git_exclude(false)
584 .build();
585
586 for entry in walker {
587 let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
588 if !entry.file_type().is_some_and(|t| t.is_file()) {
589 continue;
590 }
591
592 let path = entry.path();
593 let content = match fs::read_to_string(path) {
594 Ok(c) => c,
595 Err(_) => continue,
596 };
597
598 for (idx, line) in content.lines().enumerate() {
599 if regex.is_match(line) {
600 matches.push(SearchMatch {
601 path: path.to_path_buf(),
602 line: idx + 1,
603 content: line.to_string(),
604 });
605 }
606 if matches.len() >= 64 {
607 break;
608 }
609 }
610 if matches.len() >= 64 {
611 break;
612 }
613 }
614
615 Ok(SearchResult { matches })
616 }
617
618 fn edit_file_append(path: &Path, line: &str) -> Result<()> {
619 let mut content = read_file(path)?;
620 if !content.ends_with('\n') {
621 content.push('\n');
622 }
623 content.push_str(line);
624 content.push('\n');
625 fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
626 }
627
628 fn apply_patch(root: &Path, patch: &str) -> Result<()> {
629 let mut lines = patch.lines();
630
631 let begin = lines.next().unwrap_or_default();
632 if begin != "*** Begin Patch" {
633 return Err(anyhow!("patch missing *** Begin Patch header"));
634 }
635
636 let header = lines.next().unwrap_or_default();
637 let file_rel = header
638 .strip_prefix("*** Update File: ")
639 .ok_or_else(|| anyhow!("only *** Update File patches are supported"))?;
640 if file_rel.contains("..") {
641 return Err(anyhow!("patch path must be workspace-relative"));
642 }
643
644 let file_path = root.join(file_rel);
645 let original = read_file(&file_path)?;
646 let had_trailing_newline = original.ends_with('\n');
647 let mut file_lines: Vec<String> = original.lines().map(|l| l.to_string()).collect();
648
649 let mut cursor = 0usize;
650 for raw_line in lines {
651 if raw_line == "*** End Patch" {
652 break;
653 }
654 if raw_line.starts_with("*** ") {
655 return Err(anyhow!("unexpected patch directive: {raw_line}"));
656 }
657 if raw_line.starts_with("@@") {
658 continue;
659 }
660
661 let (kind, rest) = raw_line.split_at(1);
662 let content = rest.to_string();
663
664 match kind {
665 " " => {
666 let Some(found) = file_lines[cursor..]
667 .iter()
668 .position(|line| line == &content)
669 .map(|offset| cursor + offset)
670 else {
671 return Err(anyhow!(
672 "patch context not found in {}: {}",
673 file_path.display(),
674 content
675 ));
676 };
677 cursor = found + 1;
678 }
679 "-" => {
680 if cursor >= file_lines.len() || file_lines[cursor] != content {
681 return Err(anyhow!(
682 "patch removal mismatch in {}: expected '{}'",
683 file_path.display(),
684 content
685 ));
686 }
687 file_lines.remove(cursor);
688 }
689 "+" => {
690 file_lines.insert(cursor, content);
691 cursor += 1;
692 }
693 _ => return Err(anyhow!("unsupported patch line: {raw_line}")),
694 }
695 }
696
697 let mut updated = file_lines.join("\n");
698 if had_trailing_newline {
699 updated.push('\n');
700 }
701
702 fs::write(&file_path, updated)
703 .with_context(|| format!("failed to write patched file {}", file_path.display()))
704 }
705
706 fn exec_shell(root: &Path, command: &str) -> Result<String> {
707 #[cfg(windows)]
708 let output = Command::new("cmd")
709 .args(["/C", command])
710 .current_dir(root)
711 .output()
712 .with_context(|| format!("failed to execute shell command: {command}"))?;
713
714 #[cfg(not(windows))]
715 let output = Command::new("sh")
716 .arg("-c")
717 .arg(command)
718 .current_dir(root)
719 .output()
720 .with_context(|| format!("failed to execute shell command: {command}"))?;
721
722 if !output.status.success() {
723 let stderr = String::from_utf8_lossy(&output.stderr);
724 return Err(anyhow!(
725 "shell command failed (status={}): {}",
726 output.status,
727 stderr.trim()
728 ));
729 }
730
731 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
732 Ok(stdout.trim().to_string())
733 }
734
735 fn truncate_output(value: &str, max_chars: usize) -> String {
736 if value.chars().count() <= max_chars {
737 return value.to_string();
738 }
739
740 let truncated: String = value.chars().take(max_chars).collect();
741 format!("{}...", truncated)
742 }
743
743 lines RUST