1use crate::format::Wording;
10use agentplane::prelude::*;
11use serde_json::{Value, json};
12
13use super::model_config::{CacheTtl, PromptStyle};
14use super::propose::layout;
15
16pub const CAPABILITY: &str = "speccoder.task.acceptance";
18
19pub fn policy(w: &Wording) -> String {
21 format!(
22 "You are the test author of speccoder, writing acceptance tests for one {product} task \
23BEFORE it is implemented. Turn {examples} into \
24executable tests in the project's own language and test framework, exactly as the spec \
25states them — do not invent behaviour the spec does not state, and do not weaken an \
26example. Put them in NEW files under the project's test directory — paths that are not \
27in the project's file list; name them after the task ID (for Rust: \
28tests/acceptance_<task id>.rs, using the crate name from Cargo.toml) — and give the \
29command that runs only these tests (for Rust: cargo test --test <name>). Each file cites \
30the IDs of the scenarios it tests, from the list given (none when there is no list). The tests must \
31fail now (the task is not implemented) and pass once it is. Another model will implement \
32the task; it cannot change your tests. If the spec has no testable acceptance example \
33for this task, return no files. An example whose outcome depends on outside state — the \
34date or time, the network, files, randomness, a value published elsewhere (\"on a day \
35when …\") — is NOT a fixed assertion: never pin such a value as a constant; if the \
36behaviour cannot be tested without that state, return no files and say why in notes. \
37{convergence_task} closes a gap found after implementation: write tests that \
38show exactly that gap is closed, using only cases whose outcome the spec's wording \
39decides unambiguously.",
40 product = w.product,
41 examples = w.examples,
42 convergence_task = w.convergence_task,
43 )
44}
45
46#[must_use]
49pub fn held_out_policy(w: &Wording, composition: bool) -> String {
50 let composition = if composition {
51 " This task completes its story: also write one test that exercises the story's scenarios together (mark it `composition`, cite every scenario it covers)."
52 } else {
53 ""
54 };
55 format!(
56 "You are the second test author of speccoder, writing HELD-OUT acceptance tests for one {product} \
57task: tests the implementing model never sees, which decide whether its work does what the spec \
58says or only what the visible tests check. Cover the same scenarios as the visible tests below, \
59with OTHER inputs and the edge cases the spec itself states — never a behaviour the spec does not \
60state, never a weaker example. Each file cites the IDs of the scenarios it covers (from the list \
61given), goes to a NEW test path that is neither in the project's file list nor among the visible \
62tests, and comes with the command that runs only it (for Rust: tests/held_out_<task id>_<n>.rs, \
63cargo test --test <name>). At most four files. They must fail now and pass once the task is done as \
64the spec says. An example whose outcome depends on outside state — the date or time, the network, \
65files, randomness, a value published elsewhere — is NOT a fixed assertion: never pin such a value.{composition} \
66If no such test can be written, return no files.",
67 product = w.product,
68 )
69}
70
71#[must_use]
74pub fn strengthen_policy(w: &Wording) -> String {
75 format!(
76 "{} The task is now implemented, and acceptance tests exist (shown below). Some WRONG \
77implementations passed them (listed below). Write ADDITIONAL acceptance tests, in new files, that \
78fail for each listed wrong behaviour and pass for an implementation that does what the spec says. \
79Do not change or repeat the existing tests; never assert a behaviour the spec does not state.",
80 policy(w)
81 )
82}
83
84#[must_use]
88pub fn strengthen_input(
89 bundle: Value,
90 scenarios: &[speccoder_core::spec::ScenarioRef],
91 files: &[String],
92 manifest: &str,
93 visible: &[(String, String)],
94 weak: Value,
95 instructions: &[crate::instructions::Instruction],
96) -> Tainted<Value> {
97 Tainted::object([
98 ("instructions", instructions_part(instructions)),
99 ("set", Tainted::trusted(json!("strengthen"))),
100 ("bundle", Tainted::trusted(bundle)),
101 ("scenarios", Tainted::trusted(json!(scenarios))),
102 (
103 "project",
104 Tainted::with_label(
105 json!({ "files": files, "manifest": manifest }),
106 super::provenance::from(super::provenance::WORKTREE),
107 ),
108 ),
109 (
110 "visible",
111 Tainted::trusted(json!(
112 visible
113 .iter()
114 .map(|(p, c)| json!({ "path": p, "content": c }))
115 .collect::<Vec<_>>()
116 )),
117 ),
118 (
120 "weak",
121 Tainted::with_label(weak, super::provenance::from(super::provenance::WORKTREE)),
122 ),
123 ])
124}
125
126#[must_use]
128pub fn held_out_schema() -> Value {
129 json!({
130 "type": "object",
131 "properties": {
132 "files": {
133 "type": "array",
134 "items": {
135 "type": "object",
136 "properties": {
137 "path": { "type": "string" },
138 "content": { "type": "string" },
139 "scenarios": { "type": "array", "items": { "type": "string" } },
140 "argv": { "type": "array", "items": { "type": "string" } },
141 "composition": { "type": "boolean" },
142 },
143 "required": ["path", "content", "scenarios", "argv", "composition"],
144 "additionalProperties": false,
145 },
146 },
147 "notes": { "type": "array", "items": { "type": "string" } },
148 },
149 "required": ["files", "notes"],
150 "additionalProperties": false,
151 })
152}
153
154#[must_use]
158pub fn held_out_input(
159 bundle: Value,
160 files: &[String],
161 manifest: &str,
162 scenarios: &[speccoder_core::spec::ScenarioRef],
163 visible: &[(String, String)],
164 composition: bool,
165 instructions: &[crate::instructions::Instruction],
166) -> Tainted<Value> {
167 Tainted::object([
168 ("instructions", instructions_part(instructions)),
169 ("set", Tainted::trusted(json!("held_out"))),
170 ("bundle", Tainted::trusted(bundle)),
171 ("scenarios", Tainted::trusted(json!(scenarios))),
172 ("composition", Tainted::trusted(json!(composition))),
173 (
174 "project",
175 Tainted::with_label(
176 json!({ "files": files, "manifest": manifest }),
177 super::provenance::from(super::provenance::WORKTREE),
178 ),
179 ),
180 (
181 "visible",
183 Tainted::trusted(json!(
184 visible
185 .iter()
186 .map(|(p, c)| json!({ "path": p, "content": c }))
187 .collect::<Vec<_>>()
188 )),
189 ),
190 ])
191}
192
193#[must_use]
195pub fn schema() -> Value {
196 json!({
197 "type": "object",
198 "properties": {
199 "files": {
200 "type": "array",
201 "items": {
202 "type": "object",
203 "properties": {
204 "path": { "type": "string" },
205 "content": { "type": "string" },
206 "scenarios": { "type": "array", "items": { "type": "string" } },
207 },
208 "required": ["path", "content", "scenarios"],
209 "additionalProperties": false,
210 },
211 },
212 "argv": { "type": "array", "items": { "type": "string" } },
213 "notes": { "type": "array", "items": { "type": "string" } },
214 },
215 "required": ["files", "argv", "notes"],
216 "additionalProperties": false,
217 })
218}
219
220#[must_use]
222pub fn input(
223 bundle: Value,
224 scenarios: &[speccoder_core::spec::ScenarioRef],
225 files: &[String],
226 manifest: &str,
227 instructions: &[crate::instructions::Instruction],
228) -> Tainted<Value> {
229 Tainted::object([
232 ("instructions", instructions_part(instructions)),
233 ("bundle", Tainted::trusted(bundle)),
234 ("scenarios", Tainted::trusted(json!(scenarios))),
235 (
236 "project",
237 Tainted::with_label(
238 json!({ "files": files, "manifest": manifest }),
239 super::provenance::from(super::provenance::WORKTREE),
240 ),
241 ),
242 ])
243}
244
245fn instructions_part(instructions: &[crate::instructions::Instruction]) -> Tainted<Value> {
247 if instructions.is_empty() {
248 return Tainted::trusted(json!([]));
249 }
250 Tainted::with_label(
251 json!(instructions),
252 super::provenance::from(super::provenance::WORKTREE),
253 )
254}
255
256#[derive(Debug)]
258pub struct Acceptance {
259 pub style: PromptStyle,
261 pub cache_ttl: CacheTtl,
263 pub format: &'static dyn crate::format::Adapter,
265}
266
267#[async_trait]
268impl Skill for Acceptance {
269 fn descriptor(&self) -> SkillDescriptor {
270 SkillDescriptor::new("speccoder.task.acceptance").provides(CAPABILITY)
271 }
272
273 async fn invoke(
274 &self,
275 cx: &mut StepCtx<'_>,
276 input: Tainted<Value>,
277 ) -> Result<Outcome, SkillError> {
278 let (ttl, style) = (self.cache_ttl, self.style);
279 let held_out = input.peek()["set"] == "held_out";
280 let strengthen = input.peek()["set"] == "strengthen";
281 let prompt = super::provenance::labelled_prompt(
282 {
283 let input = input.peek();
284 let system = if held_out {
285 held_out_policy(
286 self.format.wording(),
287 input["composition"].as_bool().unwrap_or(false),
288 )
289 } else if strengthen {
290 strengthen_policy(self.format.wording())
291 } else {
292 policy(self.format.wording())
293 };
294 let mut p = layout(&input["bundle"], ttl, style, &system, self.format);
295 let files = input["project"]["files"]
296 .as_array()
297 .map(|f| {
298 f.iter()
299 .filter_map(Value::as_str)
300 .collect::<Vec<_>>()
301 .join("\n")
302 })
303 .unwrap_or_default();
304 let mut text = format!(
305 "# Project files\n\n{files}\n\n# Manifest\n\n{}",
306 input["project"]["manifest"].as_str().unwrap_or("(none)")
307 );
308 let given: Vec<crate::instructions::Instruction> =
309 serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
310 if let Some(section) = crate::instructions::section(&given) {
311 text.push_str("\n\n");
312 text.push_str(§ion);
313 }
314 let scenarios = input["scenarios"].as_array().into_iter().flatten();
315 if scenarios.clone().next().is_some() {
316 text.push_str("\n\n# Scenarios (cite these IDs)\n");
317 for s in scenarios {
318 text.push_str(&format!(
319 "\n- {}: {}",
320 s["id"].as_str().unwrap_or_default(),
321 s["text"].as_str().unwrap_or_default()
322 ));
323 }
324 }
325 if strengthen {
326 text.push_str("\n\n# The acceptance tests so far\n");
327 for f in input["visible"].as_array().into_iter().flatten() {
328 text.push_str(&format!(
329 "\n## {}\n\n```\n{}\n```\n",
330 f["path"].as_str().unwrap_or_default(),
331 f["content"].as_str().unwrap_or_default()
332 ));
333 }
334 text.push_str("\n# Wrong implementations these tests let through\n");
335 for w in input["weak"].as_array().into_iter().flatten() {
336 text.push_str(&format!(
337 "\n- {}{}",
338 w["scenario"]
339 .as_str()
340 .map(|s| format!("{s}: "))
341 .unwrap_or_default(),
342 w["wrong"].as_str().unwrap_or_default()
343 ));
344 }
345 }
346 if held_out {
347 text.push_str("\n\n# Visible acceptance tests (the implementer sees these)\n");
348 for f in input["visible"].as_array().into_iter().flatten() {
349 text.push_str(&format!(
350 "\n## {}\n\n```\n{}\n```\n",
351 f["path"].as_str().unwrap_or_default(),
352 f["content"].as_str().unwrap_or_default()
353 ));
354 }
355 }
356 let (key, kind) = match style {
357 PromptStyle::OpenAi => ("input", "input_text"),
358 PromptStyle::Anthropic | PromptStyle::Chat => ("messages", "text"),
359 };
360 if let Some(content) = p[key][0]["content"].as_array_mut() {
361 content.push(json!({ "type": kind, "text": text }));
362 }
363 p
364 },
365 &super::provenance::from(super::provenance::WORKTREE),
366 );
367 let completion = cx
368 .complete_with(&prompt, |call| {
369 call.expecting(if held_out {
370 held_out_schema()
371 } else {
372 schema()
373 })
374 })
375 .await?;
376 Ok(Outcome::done(completion.map(|c| {
377 json!({
378 "answer": c.structured,
379 "usage": serde_json::to_value(c.usage).unwrap_or(Value::Null),
380 })
381 })))
382 }
383}