harness_loop/
acceptance.rs1use async_trait::async_trait;
20use harness_core::{Context, World};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Verdict {
25 pub passed: bool,
26 pub reason: String,
29}
30
31impl Verdict {
32 pub fn passed() -> Self {
33 Self {
34 passed: true,
35 reason: String::new(),
36 }
37 }
38 pub fn failed(reason: impl Into<String>) -> Self {
39 Self {
40 passed: false,
41 reason: reason.into(),
42 }
43 }
44}
45
46#[async_trait]
48pub trait Acceptance: Send + Sync + 'static {
49 fn name(&self) -> &str;
51
52 async fn check(&self, ctx: &Context, world: &World) -> Verdict;
55}
56
57pub struct NonEmptyAnswer;
63
64#[async_trait]
65impl Acceptance for NonEmptyAnswer {
66 fn name(&self) -> &str {
67 "non-empty-answer"
68 }
69
70 async fn check(&self, ctx: &Context, _world: &World) -> Verdict {
71 let said_something = ctx
72 .history
73 .last()
74 .filter(|t| t.role == harness_core::TurnRole::Assistant)
75 .is_some_and(|t| {
76 t.blocks.iter().any(|b| match b {
77 harness_core::Block::Text(s) => !s.trim().is_empty(),
78 _ => false,
79 })
80 });
81 if said_something {
82 Verdict::passed()
83 } else {
84 Verdict::failed(
85 "You ended that turn without answering and without calling a tool — you only \
86 thought about it. Nothing you described has actually been done yet. Carry on \
87 now: call the tools you need, and when the work is really finished, say so.",
88 )
89 }
90 }
91}
92
93pub struct FilesExist {
98 paths: Vec<String>,
99}
100
101impl FilesExist {
102 pub fn new<I, S>(paths: I) -> Self
103 where
104 I: IntoIterator<Item = S>,
105 S: Into<String>,
106 {
107 Self {
108 paths: paths.into_iter().map(Into::into).collect(),
109 }
110 }
111}
112
113#[async_trait]
114impl Acceptance for FilesExist {
115 fn name(&self) -> &str {
116 "files-exist"
117 }
118
119 async fn check(&self, _ctx: &Context, world: &World) -> Verdict {
120 let root = &world.repo.root;
121 let missing: Vec<&str> = self
122 .paths
123 .iter()
124 .filter(|p| {
125 let full = root.join(p);
126 !std::fs::metadata(&full)
129 .map(|m| m.len() > 0)
130 .unwrap_or(false)
131 })
132 .map(String::as_str)
133 .collect();
134 if missing.is_empty() {
135 Verdict::passed()
136 } else {
137 Verdict::failed(format!(
138 "These files were expected but are missing or empty: {}. \
139 The work is not done — create them properly, then say so.",
140 missing.join(", ")
141 ))
142 }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use harness_core::{Block, Turn, TurnRole};
150
151 fn ctx_with(blocks: Vec<Block>) -> Context {
152 let mut c = Context::new(harness_core::Task {
153 description: "do it".into(),
154 source: None,
155 deadline: None,
156 });
157 c.history.push(Turn {
158 role: TurnRole::Assistant,
159 blocks,
160 });
161 c
162 }
163
164 #[tokio::test]
165 async fn thinking_out_loud_is_not_an_answer() {
166 let world = harness_context::default_world(".");
167
168 let only_reasoning = ctx_with(vec![Block::Reasoning("I'll write the docx next".into())]);
169 let v = NonEmptyAnswer.check(&only_reasoning, &world).await;
170 assert!(!v.passed);
171 assert!(v.reason.contains("Carry on"), "the reason is actionable");
172
173 let blank = ctx_with(vec![Block::Text(" \n".into())]);
175 assert!(!NonEmptyAnswer.check(&blank, &world).await.passed);
176
177 let answered = ctx_with(vec![Block::Text("Done — resume.docx is written.".into())]);
178 assert!(NonEmptyAnswer.check(&answered, &world).await.passed);
179 }
180
181 #[tokio::test]
182 async fn a_promised_file_has_to_be_there_and_have_bytes_in_it() {
183 let dir = std::env::temp_dir().join(format!("acceptance-{}", std::process::id()));
184 std::fs::create_dir_all(&dir).unwrap();
185 let world = harness_context::default_world(&dir);
186 let ctx = ctx_with(vec![Block::Text("all done!".into())]);
187
188 let check = FilesExist::new(["out.docx"]);
189 let v = check.check(&ctx, &world).await;
190 assert!(!v.passed, "absent");
191 assert!(v.reason.contains("out.docx"), "names what's missing: {v:?}");
192
193 std::fs::write(dir.join("out.docx"), b"").unwrap();
195 assert!(!check.check(&ctx, &world).await.passed, "empty");
196
197 std::fs::write(dir.join("out.docx"), b"PK\x03\x04").unwrap();
198 assert!(check.check(&ctx, &world).await.passed);
199
200 let _ = std::fs::remove_dir_all(&dir);
201 }
202}