fix(missions): a repo-less mission threw away everything its agents wrote

`capture_finished_coding_phases` selects `AND m.repo_id IS NOT NULL`. Every
`research_only` mission is repo-less by design (`requires_repo = false`), so the
whole capture path — including the `sync_out` that copies the agent's work OUT
of the container — never ran, and the container was reaped unread.

Measured on the real mission `019fdc35` ("ClawHDF5 Research"): four agents, 9.5
minutes, EIGHT research documents — an HDF5 parser design, a Rust ecosystem
survey, a seven-crate dependency map, tracing and fuzzing strategy. Result:
`mission_artifacts` = 0, mission `completed`. Not recoverable: no container, no
volume, nothing under the missions root.

The platform did not merely fail to save the work — it INSTRUCTED it. The task
preamble tells every agent "/mission/repo ... is the mission's git checkout",
whether or not one exists, and the research directive says to save findings
there. One agent recorded the contradiction verbatim: "No git repo — file is
written." It looked, saw no repo, complied anyway.

Three changes, one per link in that chain:

1. `mission_outputs::capture_repo_less_phases` — copies `/mission/repo` out of
   the container and registers each file as an artifact under `_outputs/`,
   which is a SIBLING of the mission dir and survives `teardown_container`.
   This is also the code that finally reads `produces`, until now an inert key:
   `produces = ["md","pdf"]` now drives `render_pdf` into the existing
   pdf_renderer worker.

2. The preamble is conditional. A repo-less mission is told its workspace is
   scratch, that git_operations has nothing to act on, and — the part that
   matters — that files left there ARE collected and published. An agent told
   only "there is no repo" has no reason to write anything to disk.

3. A repo-less phase that produced no files is FAILED, unless it declares
   `allow_empty`. The same rule `empty_delivery_is_a_failure` applies to coding,
   for the only channel these phases have. Note this is NOT that guard widened:
   it keys on `files_changed`, which is meaningless with no checkout, and would
   not have saved the ClawHDF5 documents.

Negative controls, each ablated and confirmed failing: ignore `has_repo` and the
preamble test fails; empty the skip-list and the capture test keeps `.git` and
`node_modules`; write artifacts inside the mission dir and the survives-the-reap
test fails.

236 lib tests pass.
This commit is contained in:
Omar Sobh
2026-08-07 11:28:10 -07:00
parent bcf4866abc
commit ceab28b902
3 changed files with 414 additions and 11 deletions
+1
View File
@@ -26,6 +26,7 @@ pub mod microvm_executor;
pub mod microvm_turn_executor; pub mod microvm_turn_executor;
pub mod vm_stop_gate; pub mod vm_stop_gate;
pub mod mission_fs; pub mod mission_fs;
pub mod mission_outputs;
pub mod papers; pub mod papers;
pub mod phase_config; pub mod phase_config;
pub mod session_executor; pub mod session_executor;
+330
View File
@@ -0,0 +1,330 @@
//! Capture for missions that have no repository.
//!
//! `mission_delivery` captures a phase's work by diffing a git checkout. A
//! mission with `repo_id IS NULL` — every `research_only` mission, because that
//! recipe sets `requires_repo = false` — has no checkout, so
//! `capture_finished_coding_phases` filters it out at the SQL level
//! (`AND m.repo_id IS NOT NULL`) and never reads the container at all.
//!
//! The agents still write files. The research directive tells them to save
//! findings under `/mission/repo/research/`, and it says so whether or not a
//! repo exists. So the work lands in the container's own filesystem, is never
//! collected, and is destroyed when the sweeper reaps the container.
//!
//! # What this cost, measured
//!
//! Mission `019fdc35` ("ClawHDF5 Research"): four agents, 9.5 minutes, **eight
//! research documents** — an HDF5 parser design, a Rust ecosystem survey, a
//! seven-crate dependency map, tracing and fuzzing strategy. `mission_artifacts`
//! held zero rows and the mission reported `completed`. One agent's own summary
//! recorded the situation exactly: *"No git repo — file is written."* It noticed,
//! wrote anyway, and the platform threw the result away without a word.
//!
//! Nothing survived but the summarizer's account of it — which is the agents'
//! description of the work, not the work.
//!
//! # Why a separate path rather than widening the diff capture
//!
//! There is no base commit to diff against and no branch to push, so every
//! concept `capture_phase_diff` is built on is absent. What a repo-less mission
//! produces is simply *files*, and the honest capture is to copy them out and
//! register each as an artifact. `_outputs/` is deliberately a SIBLING of the
//! mission directory and survives `teardown_container`, so artifacts registered
//! here outlive the reap that destroyed the originals.
use std::path::{Path, PathBuf};
use sqlx::{PgPool, Row};
use uuid::Uuid;
/// Directories never worth capturing, whatever an agent leaves behind.
///
/// Same intent as `mission_fs`'s exclusion list: a captured `.git` or
/// `node_modules` is noise that would bury the four documents that matter.
const SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
".venv",
"venv",
"__pycache__",
".cache",
"dist",
"build",
];
/// How many phases to capture per tick, matching `CAPTURE_BATCH`.
const BATCH: i64 = 5;
/// The artifact kind this path registers. Also the idempotency key: a phase with
/// one of these has already been captured.
pub const OUTPUT_KIND: &str = "document";
/// Capture the outputs of finished phases on missions that have no repo.
pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
let rows = sqlx::query(
"SELECT mp.id, mp.mission_id, mp.kind, mp.config
FROM mission_phases mp
JOIN missions m ON m.id = mp.mission_id
WHERE mp.status IN ('completed', 'failed')
AND m.repo_id IS NULL
-- A microVM mission always has a checkout (`run_phase_in_vm` refuses
-- to boot without one), so this path is container-only.
AND m.runtime_kind <> 'microvm'
AND NOT EXISTS (
SELECT 1 FROM mission_artifacts a
WHERE a.mission_id = mp.mission_id
AND a.phase_id = mp.id
AND a.kind = $2
)
ORDER BY mp.completed_at DESC NULLS LAST
LIMIT $1",
)
.bind(BATCH)
.bind(OUTPUT_KIND)
.fetch_all(pool)
.await
.map_err(|e| format!("select repo-less phases to capture: {e}"))?;
for row in rows {
let phase_id: Uuid = row.get("id");
let mission_id: Uuid = row.get("mission_id");
let kind: String = row.get("kind");
let config: serde_json::Value = row.get("config");
let dest = outputs_dir(mission_id, phase_id);
let captured = match collect_into(mission_id, &dest).await {
Ok(files) => files,
Err(e) => {
// Loud and retryable, never silently "captured nothing": the
// whole defect this module exists for is work disappearing
// without a word. The next tick tries again; if the container is
// already gone the phase is failed below on the next pass.
eprintln!(
"mission_outputs: could NOT collect outputs for phase {phase_id} \
of mission {mission_id}: {e}"
);
continue;
}
};
let wants_pdf = produces_pdf(&config);
for file in &captured {
let rel = match file.strip_prefix(missions_root()) {
Ok(r) => r.to_string_lossy().to_string(),
Err(_) => file.to_string_lossy().to_string(),
};
let title = file
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| rel.clone());
if let Err(e) = cm_db::repo::missions::register_artifact(
pool,
cm_db::repo::missions::RegisterArtifact {
mission_id,
phase_id: Some(phase_id),
path: &rel,
kind: OUTPUT_KIND,
mime: Some(mime_for(file)),
title: Some(&title),
generated_by_run: None,
// `produces` used to be inert — declared by the recipe and
// read by nothing. This is the code that reads it.
render_pdf: wants_pdf && is_markdown(file),
metadata: Some(serde_json::json!({
"bytes": std::fs::metadata(file).map(|m| m.len()).unwrap_or(0),
"captured_from": "/mission/repo",
})),
},
)
.await
{
eprintln!("mission_outputs: registering {rel}: {e}");
}
}
if captured.is_empty() && !allow_empty(&config) {
// The same rule `empty_delivery_is_a_failure` applies to a coding
// phase, for the only channel a repo-less phase has. Without it a
// research mission that produced nothing is indistinguishable from
// one that produced eight documents — both `completed`.
eprintln!(
"mission_outputs: phase {phase_id} ({kind}) of mission {mission_id} produced \
NO output files — failing it. Set config.allow_empty = true if this phase is \
meant to think rather than produce."
);
if let Err(e) = sqlx::query("UPDATE mission_phases SET status = 'failed' WHERE id = $1")
.bind(phase_id)
.execute(pool)
.await
{
eprintln!("mission_outputs: failing empty phase {phase_id}: {e}");
}
} else {
eprintln!(
"mission_outputs: captured {} file(s) from phase {phase_id} ({kind}) of \
mission {mission_id}",
captured.len()
);
}
}
Ok(())
}
/// Copy `/mission/repo` out of the mission's container and return the files kept.
async fn collect_into(mission_id: Uuid, dest: &Path) -> Result<Vec<PathBuf>, String> {
let container = crate::mission_runtime::container_name(mission_id);
let docker = crate::container_exec::connect()?;
// A stale copy from an earlier attempt would be registered as this pass's
// output — the same "captured a tree nobody wrote" shape capture avoids.
let _ = std::fs::remove_dir_all(dest);
std::fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
crate::mission_fs::copy_out(&docker, &container, "/mission/repo", dest).await?;
Ok(keep_files(&dest.join("repo")))
}
/// Every regular file worth keeping, recursively.
fn keep_files(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
if !SKIP_DIRS.contains(&name.as_str()) {
stack.push(path);
}
} else if path.is_file() && !name.starts_with('.') {
out.push(path);
}
}
}
out.sort();
out
}
/// `<missions_root>/_outputs/<mission>/<phase>` — a sibling of the mission
/// directory, so `teardown_container` reaping the mission does not take the
/// captured artifacts with it.
fn outputs_dir(mission_id: Uuid, phase_id: Uuid) -> PathBuf {
missions_root()
.join("_outputs")
.join(mission_id.to_string())
.join(phase_id.to_string())
}
fn missions_root() -> PathBuf {
std::env::var("CLAWMATES_MISSIONS_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions"))
}
fn is_markdown(p: &Path) -> bool {
matches!(
p.extension().and_then(|e| e.to_str()),
Some("md") | Some("markdown")
)
}
fn mime_for(p: &Path) -> &'static str {
match p.extension().and_then(|e| e.to_str()) {
Some("md") | Some("markdown") => "text/markdown",
Some("json") => "application/json",
Some("csv") => "text/csv",
Some("html") => "text/html",
_ => "text/plain",
}
}
/// Did the recipe ask for a PDF? `research_only.toml` declares
/// `produces = ["md","pdf"]`.
fn produces_pdf(config: &serde_json::Value) -> bool {
config
.get("produces")
.and_then(|v| v.as_array())
.map(|a| a.iter().any(|v| v.as_str() == Some("pdf")))
.unwrap_or(false)
}
fn allow_empty(config: &serde_json::Value) -> bool {
config.get("allow_empty").and_then(|v| v.as_bool()) == Some(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(p: &Path) {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, "x").unwrap();
}
/// The documents a research phase writes are what must come back — and the
/// machinery around them must not.
#[test]
fn research_documents_are_kept_and_scaffolding_is_not() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
touch(&repo.join("research/01_repo_archaeology.md"));
touch(&repo.join("research/02_ecosystem.md"));
touch(&repo.join("notes.txt"));
touch(&repo.join(".git/HEAD"));
touch(&repo.join("node_modules/left-pad/index.js"));
touch(&repo.join("target/debug/thing"));
touch(&repo.join(".hidden"));
let kept: Vec<String> = keep_files(&repo)
.iter()
.map(|p| p.strip_prefix(&repo).unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(
kept,
vec![
"notes.txt".to_string(),
"research/01_repo_archaeology.md".to_string(),
"research/02_ecosystem.md".to_string(),
],
"kept: {kept:?}"
);
}
/// `produces` was registered as an INERT key — declared by the recipe and
/// read by nothing, which is why `research_only`'s promised PDF never
/// appeared. This is the code that makes it mean something.
#[test]
fn the_recipes_produces_key_decides_pdf_rendering() {
let with = serde_json::json!({ "produces": ["md", "pdf"] });
let without = serde_json::json!({ "produces": ["md"] });
let absent = serde_json::json!({});
assert!(produces_pdf(&with));
assert!(!produces_pdf(&without));
assert!(!produces_pdf(&absent));
// Only markdown is rendered; a captured JSON side-file is not a document
// to typeset.
assert!(is_markdown(Path::new("/x/01_notes.md")));
assert!(!is_markdown(Path::new("/x/data.json")));
}
/// Artifacts must land OUTSIDE the mission directory. `teardown_container`
/// removes `<missions_root>/<mission_id>` wholesale, so a capture written
/// inside it would be destroyed by the very reap it exists to survive.
#[test]
fn captures_survive_the_mission_directory_being_reaped() {
let mission = Uuid::now_v7();
let phase = Uuid::now_v7();
let out = outputs_dir(mission, phase);
let mission_dir = missions_root().join(mission.to_string());
assert!(
!out.starts_with(&mission_dir),
"{} must not be inside {}",
out.display(),
mission_dir.display()
);
assert!(out.starts_with(missions_root().join("_outputs")), "{out:?}");
}
}
+83 -11
View File
@@ -68,6 +68,9 @@ async fn sweep_once(
// checkout. Idempotent, so a failure here is retried on the next tick // checkout. Idempotent, so a failure here is retried on the next tick
// rather than losing the phase's work. // rather than losing the phase's work.
capture_finished_coding_phases(pool).await?; capture_finished_coding_phases(pool).await?;
// The same, for missions with no repository to diff. Without this the
// container holding a research phase's only output is reaped unread.
crate::mission_outputs::capture_repo_less_phases(pool).await?;
// A failed phase makes every later phase unreachable, and saying so is what // A failed phase makes every later phase unreachable, and saying so is what
// lets the mission finish at all. // lets the mission finish at all.
skip_unreachable_phases(pool).await?; skip_unreachable_phases(pool).await?;
@@ -212,6 +215,44 @@ async fn capture_finished_coding_phases(pool: &PgPool) -> Result<(), String> {
Ok(()) Ok(())
} }
/// A repo-less mission must not be told its scratch directory is a git checkout.
///
/// The eight ClawHDF5 research documents were written because the preamble said
/// "/mission/repo ... is the mission's git checkout" to a mission that had none.
/// One agent recorded the contradiction verbatim — "No git repo — file is
/// written" — and wrote into a container that was then reaped unread.
#[cfg(test)]
mod repo_less_text_tests {
use super::*;
#[test]
fn a_repo_less_phase_is_not_told_it_has_a_checkout() {
let with = phase_task_text("research", "T", None, None, true);
let without = phase_task_text("research", "T", None, None, false);
assert!(with.contains("mission's git checkout"), "{with}");
assert!(
!without.contains("git checkout"),
"a mission with no repo must not be promised one: {without}"
);
// And it must say what DOES happen to the files, or an agent told only
// "there is no repo" has no reason to write them to disk at all.
assert!(without.contains("published as a mission artifact"), "{without}");
assert!(without.contains("NO git repository"), "{without}");
}
/// Both variants must keep the instruction that outputs are real files.
/// That line is what stops an agent pasting its work into the reply, and it
/// is load-bearing for the capture path either way.
#[test]
fn both_variants_still_demand_files_on_disk() {
for has_repo in [true, false] {
let t = phase_task_text("research", "T", None, None, has_repo);
assert!(t.contains("REAL files with file_edit"), "has_repo={has_repo}: {t}");
}
}
}
/// Did this phase finish without delivering the work it exists to produce? /// Did this phase finish without delivering the work it exists to produce?
/// ///
/// A coding phase that changes no files has done nothing, and until now that /// A coding phase that changes no files has done nothing, and until now that
@@ -263,7 +304,10 @@ async fn start_pending_phases(
-- Where and how this mission executes. `runtime_kind` decides -- Where and how this mission executes. `runtime_kind` decides
-- which executor takes the phase; without it 'microvm' is a -- which executor takes the phase; without it 'microvm' is a
-- value the placement code honours and nothing reads. -- value the placement code honours and nothing reads.
m.runtime_kind, m.backend, m.target_node_id, m.team_engine m.runtime_kind, m.backend, m.target_node_id, m.team_engine,
-- Whether a checkout exists at all. A repo-less mission's
-- /mission/repo is scratch space, and the task text must say so.
(m.repo_id IS NOT NULL) AS has_repo
FROM mission_phases mp FROM mission_phases mp
JOIN missions m ON m.id = mp.mission_id JOIN missions m ON m.id = mp.mission_id
WHERE mp.status = 'pending' WHERE mp.status = 'pending'
@@ -294,6 +338,7 @@ async fn start_pending_phases(
let backend: Option<String> = row.get("backend"); let backend: Option<String> = row.get("backend");
let target_node_id: Option<Uuid> = row.get("target_node_id"); let target_node_id: Option<Uuid> = row.get("target_node_id");
let team_engine: Option<String> = row.get("team_engine"); let team_engine: Option<String> = row.get("team_engine");
let has_repo: bool = row.get("has_repo");
if let Err(e) = launch_phase( if let Err(e) = launch_phase(
pool, pool,
@@ -312,6 +357,7 @@ async fn start_pending_phases(
backend: backend.as_deref(), backend: backend.as_deref(),
target_node_id, target_node_id,
team_engine: team_engine.as_deref(), team_engine: team_engine.as_deref(),
has_repo,
}, },
) )
.await .await
@@ -354,6 +400,10 @@ struct PhaseLaunch<'a> {
target_node_id: Option<Uuid>, target_node_id: Option<Uuid>,
/// `missions.team_engine`. NULL = solo. /// `missions.team_engine`. NULL = solo.
team_engine: Option<&'a str>, team_engine: Option<&'a str>,
/// Whether `missions.repo_id` is set. Decides whether the agents are told
/// `/mission/repo` is a git checkout or a scratch workspace whose contents
/// are captured as artifacts.
has_repo: bool,
} }
async fn launch_phase( async fn launch_phase(
@@ -369,6 +419,7 @@ async fn launch_phase(
title, title,
description, description,
phase_task, phase_task,
has_repo,
iteration, iteration,
// Destructured but read through `p` below, so the compiler keeps this // Destructured but read through `p` below, so the compiler keeps this
// pattern honest if a field is added. // pattern honest if a field is added.
@@ -505,7 +556,7 @@ async fn launch_phase(
let prior = crate::evaluator::latest(pool, phase_id) let prior = crate::evaluator::latest(pool, phase_id)
.await .await
.unwrap_or(None); .unwrap_or(None);
let task = phase_task_text(kind, title, description, phase_task); let task = phase_task_text(kind, title, description, phase_task, has_repo);
let task = match prior { let task = match prior {
Some((iter, false, guidance)) => format!( Some((iter, false, guidance)) => format!(
"{task}\n\nPASS {} DID NOT SATISFY THE COMPLETION CONDITION. What is \ "{task}\n\nPASS {} DID NOT SATISFY THE COMPLETION CONDITION. What is \
@@ -1084,6 +1135,11 @@ fn phase_task_text(
title: &str, title: &str,
description: Option<&str>, description: Option<&str>,
phase_task: Option<&str>, phase_task: Option<&str>,
// Whether the mission has a repository bound. A repo-less mission's
// `/mission/repo` is a scratch directory, and telling its agents otherwise
// is how eight research documents were written into a container that was
// then reaped unread.
has_repo: bool,
) -> String { ) -> String {
let base = description.unwrap_or("").trim(); let base = description.unwrap_or("").trim();
// The prior template-derived system prompts trained agents to look // The prior template-derived system prompts trained agents to look
@@ -1093,7 +1149,7 @@ fn phase_task_text(
// the real tool inventory + concrete workspace path stops the agent // the real tool inventory + concrete workspace path stops the agent
// from hallucinating "I only have file_read" and dumping the entire // from hallucinating "I only have file_read" and dumping the entire
// implementation into the context window instead of onto disk. // implementation into the context window instead of onto disk.
let tool_preamble = "\ let tool_preamble = format!("\
TOOLS AVAILABLE (use these exact names — do NOT assume older tool names like file_read / file_write / bash exist):\n\ TOOLS AVAILABLE (use these exact names — do NOT assume older tool names like file_read / file_write / bash exist):\n\
- file_edit — create, overwrite, or patch files in your workspace\n\ - file_edit — create, overwrite, or patch files in your workspace\n\
- content_search — grep across your workspace (regex on file contents)\n\ - content_search — grep across your workspace (regex on file contents)\n\
@@ -1105,12 +1161,28 @@ fn phase_task_text(
- delegate — call a peer role by name\n\ - delegate — call a peer role by name\n\
- memory_store / memory_recall — durable per-agent notes\n\ - memory_store / memory_recall — durable per-agent notes\n\
\n\ \n\
WORKSPACE: Your working directory is /mission/repo. That path is the\n\ {workspace}\n\
mission's git checkout. All file_edit / content_search / glob_search\n\ All file_edit / content_search / glob_search\n\
operations resolve there. To read a file: file_edit with mode='read'\n\ operations resolve there. To read a file: file_edit with mode='read'\n\
or content_search first, then file_edit to patch. Write your outputs\n\ or content_search first, then file_edit to patch. Write your outputs\n\
as REAL files with file_edit — do NOT paste code blocks in your reply\n\ as REAL files with file_edit — do NOT paste code blocks in your reply\n\
expecting the platform to save them; nothing else writes files for you.\n"; expecting the platform to save them; nothing else writes files for you.\n",
workspace = if has_repo {
"WORKSPACE: Your working directory is /mission/repo. That path is the\n\
mission's git checkout."
} else {
// Saying "git checkout" here to a mission that has none is what
// produced the eight destroyed ClawHDF5 documents: the agent looked,
// found no repo, wrote the files anyway, and nothing collected them.
// Now `mission_outputs` DOES collect them, and the agent is told so
// — an instruction the platform can actually keep.
"WORKSPACE: Your working directory is /mission/repo. This mission has\n\
NO git repository — that path is a scratch workspace, so git_operations\n\
and git_forge have nothing to act on. Every file you leave there is\n\
collected when the phase ends and published as a mission artifact, so\n\
write your output as files exactly as you would in a repo."
}
);
// The INT-XX markers are a machine contract, not a style preference: // The INT-XX markers are a machine contract, not a style preference:
// task_card_parser.rs scans turn output line-by-line for these literals and // task_card_parser.rs scans turn output line-by-line for these literals and
// materializes `mission_tasks` rows from them. The rules used to live only // materializes `mission_tasks` rows from them. The rules used to live only
@@ -1625,8 +1697,8 @@ mod tests {
#[test] #[test]
fn phase_task_reaches_the_agent_and_distinguishes_phases() { fn phase_task_reaches_the_agent_and_distinguishes_phases() {
let brief = Some("Add two marker files."); let brief = Some("Add two marker files.");
let alpha = phase_task_text("coding", "Demo", brief, Some("Create ALPHA.md")); let alpha = phase_task_text("coding", "Demo", brief, Some("Create ALPHA.md"), true);
let beta = phase_task_text("coding", "Demo", brief, Some("Create BETA.md")); let beta = phase_task_text("coding", "Demo", brief, Some("Create BETA.md"), true);
assert!(alpha.contains("Create ALPHA.md"), "phase task must be injected"); assert!(alpha.contains("Create ALPHA.md"), "phase task must be injected");
assert!(beta.contains("Create BETA.md")); assert!(beta.contains("Create BETA.md"));
@@ -1634,10 +1706,10 @@ mod tests {
assert_ne!(alpha, beta, "sibling phases received identical instructions"); assert_ne!(alpha, beta, "sibling phases received identical instructions");
// A phase with no task of its own is unchanged from before the fix. // A phase with no task of its own is unchanged from before the fix.
let bare = phase_task_text("coding", "Demo", brief, None); let bare = phase_task_text("coding", "Demo", brief, None, true);
assert!(!bare.contains("THIS PHASE'S TASK")); assert!(!bare.contains("THIS PHASE'S TASK"));
// Empty and whitespace-only configs take the same path as absent. // Empty and whitespace-only configs take the same path as absent.
assert_eq!(bare, phase_task_text("coding", "Demo", brief, Some(" "))); assert_eq!(bare, phase_task_text("coding", "Demo", brief, Some(" "), true));
} }
/// The marker syntax we hand the agent must be the syntax we parse back. /// The marker syntax we hand the agent must be the syntax we parse back.
@@ -1648,7 +1720,7 @@ mod tests {
/// Every example line in the prompt is fed through the real parser here. /// Every example line in the prompt is fed through the real parser here.
#[test] #[test]
fn task_text_marker_examples_parse() { fn task_text_marker_examples_parse() {
let text = phase_task_text("coding", "Demo", Some("brief"), None); let text = phase_task_text("coding", "Demo", Some("brief"), None, true);
let examples: Vec<&str> = text let examples: Vec<&str> = text
.lines() .lines()