Skip to main content

speccoder_runtime/substrate/
acceptance.rs

1//! `speccoder.task.acceptance`: derive acceptance tests from the spec before
2//! an attempt.
3//!
4//! A judge-side call: no tools, no say in the verdict, and the implementing
5//! model never sees it as its own work. speccoder validates what comes back,
6//! writes it as new (hence frozen) test files, and requires red before and
7//! green after the attempt.
8
9use crate::format::Wording;
10use agentplane::prelude::*;
11use serde_json::{Value, json};
12
13use super::model_config::{CacheTtl, PromptStyle};
14use super::propose::layout;
15
16/// The capability the skill provides.
17pub const CAPABILITY: &str = "speccoder.task.acceptance";
18
19/// What the test author is told.
20pub 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/// What the held-out author is told: the visible tests' scenarios again,
47/// with other inputs, for tests the implementer never sees.
48#[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/// What the author is told when the set was too weak: the same
72/// task, the tests so far, and the wrong implementations they let through.
73#[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/// The input for strengthening a weak set: the visible run's, plus the
85/// tests so far and what got through (described by the judge, never shown
86/// to the implementer).
87#[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        // Written by the near-miss author and the mutation tool from the change.
119        (
120            "weak",
121            Tainted::with_label(weak, super::provenance::from(super::provenance::WORKTREE)),
122        ),
123    ])
124}
125
126/// The held-out answer's schema: per file its scenarios and command.
127#[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/// The held-out author's input: the visible run's, plus the scenarios, the
155/// visible tests (so the held-out ones differ) and whether this task
156/// completes its story.
157#[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            // Written by speccoder's own author call from the spec.
182            "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/// The answer's schema.
194#[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/// The run input: `{ bundle, scenarios, project: { files, manifest } }`.
221#[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    // The bundle and its scenarios are the person's; the file list,
230    // manifest and instructions are the worktree's.
231    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
245/// The repository's instructions, labelled as the worktree's.
246fn 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/// The coded skill.
257#[derive(Debug)]
258pub struct Acceptance {
259    /// Prompt dialect.
260    pub style: PromptStyle,
261    /// Cache breakpoint lifetime.
262    pub cache_ttl: CacheTtl,
263    /// The project's spec format.
264    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(&section);
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}