From cf90ae1a5f13c76ba832e334805433e7e248fae8 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:18:43 +0200 Subject: [PATCH 01/37] fix: trigger push CI on master, not nonexistent main --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c23cc7b..91493e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [master] pull_request: permissions: From 21d533f7a727cde1f3a91269e40e174152e19416 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:18:43 +0200 Subject: [PATCH 02/37] chore: expand gitignore, drop generated doc/tags --- .gitignore | 4 ++++ doc/tags | 23 ----------------------- 2 files changed, 4 insertions(+), 23 deletions(-) delete mode 100644 doc/tags diff --git a/.gitignore b/.gitignore index ea8c4bf..d567efc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ /target +/doc/tags +loopbioticd-v*.tar.gz +loopbioticd-v*.tar.gz.sha256 +.luarc.local.json diff --git a/doc/tags b/doc/tags deleted file mode 100644 index 1aad93f..0000000 --- a/doc/tags +++ /dev/null @@ -1,23 +0,0 @@ -:Loopbiotic loopbiotic.txt /*:Loopbiotic* -:LoopbioticAgent loopbiotic.txt /*:LoopbioticAgent* -:LoopbioticAssess loopbiotic.txt /*:LoopbioticAssess* -:LoopbioticFix loopbiotic.txt /*:LoopbioticFix* -:LoopbioticFollow loopbiotic.txt /*:LoopbioticFollow* -:LoopbioticHide loopbiotic.txt /*:LoopbioticHide* -:LoopbioticLog loopbiotic.txt /*:LoopbioticLog* -:LoopbioticLogClear loopbiotic.txt /*:LoopbioticLogClear* -:LoopbioticModel loopbiotic.txt /*:LoopbioticModel* -:LoopbioticNext loopbiotic.txt /*:LoopbioticNext* -:LoopbioticOther loopbiotic.txt /*:LoopbioticOther* -:LoopbioticReply loopbiotic.txt /*:LoopbioticReply* -:LoopbioticReset loopbiotic.txt /*:LoopbioticReset* -:LoopbioticResume loopbiotic.txt /*:LoopbioticResume* -:LoopbioticStop loopbiotic.txt /*:LoopbioticStop* -:LoopbioticWhy loopbiotic.txt /*:LoopbioticWhy* -loopbiotic loopbiotic.txt /*loopbiotic* -loopbiotic-commands loopbiotic.txt /*loopbiotic-commands* -loopbiotic-contents loopbiotic.txt /*loopbiotic-contents* -loopbiotic-health loopbiotic.txt /*loopbiotic-health* -loopbiotic-privacy loopbiotic.txt /*loopbiotic-privacy* -loopbiotic-setup loopbiotic.txt /*loopbiotic-setup* -loopbiotic.txt loopbiotic.txt /*loopbiotic.txt* From bde371b921ac28549122196efd1c4333a0f2af90 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:18:43 +0200 Subject: [PATCH 03/37] docs: move ml.md under doc/ --- README.md | 2 +- ml.md => doc/ml.md | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename ml.md => doc/ml.md (100%) diff --git a/README.md b/README.md index 984d131..0155a92 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ Choosing `Fix` on a card first moves the source context to the card's The patch agent therefore receives the recommended consumer or template instead of the file where discovery happened. -The future optional classical-ML ranking design is documented in [`ml.md`](ml.md). +The future optional classical-ML ranking design is documented in [`doc/ml.md`](doc/ml.md). The current implementation does not train or run an ML model. ## Commands diff --git a/ml.md b/doc/ml.md similarity index 100% rename from ml.md rename to doc/ml.md From 6a7afd83d045ccd284f9804d2aa9425bbe2306ec Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:18:43 +0200 Subject: [PATCH 04/37] chore: assert CHANGELOG entry for the release version --- scripts/check-versions.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check-versions.sh b/scripts/check-versions.sh index ad15f34..581425e 100755 --- a/scripts/check-versions.sh +++ b/scripts/check-versions.sh @@ -10,5 +10,6 @@ test -n "$cargo_version" test "$cargo_version" = "$lua_version" test -n "$rust_protocol" test "$rust_protocol" = "$lua_protocol" +grep -q "^## \[$cargo_version\]" CHANGELOG.md echo "Versions match: v$cargo_version, protocol $rust_protocol" From fb75860d5bea64d1999638aa02a466d69ffae3df Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:18:43 +0200 Subject: [PATCH 05/37] chore: drop unused thiserror, dev-scope async-trait, delete dead actions module --- Cargo.lock | 22 ------------------- Cargo.toml | 1 - rust/crates/loopbiotic_harness/Cargo.toml | 5 +++-- rust/crates/loopbiotic_harness/src/actions.rs | 1 - rust/crates/loopbiotic_harness/src/lib.rs | 1 - rust/crates/loopbiotic_patch/Cargo.toml | 1 - 6 files changed, 3 insertions(+), 28 deletions(-) delete mode 100644 rust/crates/loopbiotic_harness/src/actions.rs diff --git a/Cargo.lock b/Cargo.lock index 14e753e..609b375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,7 +397,6 @@ dependencies = [ "loopbiotic_patch", "loopbiotic_protocol", "serde_json", - "thiserror", "tokio", "uuid", ] @@ -408,7 +407,6 @@ version = "0.3.2" dependencies = [ "anyhow", "loopbiotic_protocol", - "thiserror", ] [[package]] @@ -696,26 +694,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tinystr" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index e7495ee..eaca118 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,5 @@ reqwest = { version = "0.12", default-features = false, features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" -thiserror = "2" tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "sync", "time"] } uuid = { version = "1", features = ["v4"] } diff --git a/rust/crates/loopbiotic_harness/Cargo.toml b/rust/crates/loopbiotic_harness/Cargo.toml index 842f44e..68eb3af 100644 --- a/rust/crates/loopbiotic_harness/Cargo.toml +++ b/rust/crates/loopbiotic_harness/Cargo.toml @@ -10,12 +10,13 @@ publish = false [dependencies] anyhow.workspace = true -async-trait.workspace = true loopbiotic_backends = { path = "../loopbiotic_backends" } loopbiotic_context = { path = "../loopbiotic_context" } loopbiotic_patch = { path = "../loopbiotic_patch" } loopbiotic_protocol = { path = "../loopbiotic_protocol" } serde_json.workspace = true -thiserror.workspace = true tokio.workspace = true uuid.workspace = true + +[dev-dependencies] +async-trait.workspace = true diff --git a/rust/crates/loopbiotic_harness/src/actions.rs b/rust/crates/loopbiotic_harness/src/actions.rs deleted file mode 100644 index 30b9435..0000000 --- a/rust/crates/loopbiotic_harness/src/actions.rs +++ /dev/null @@ -1 +0,0 @@ -pub use loopbiotic_protocol::Action; diff --git a/rust/crates/loopbiotic_harness/src/lib.rs b/rust/crates/loopbiotic_harness/src/lib.rs index 34ac898..f43acea 100644 --- a/rust/crates/loopbiotic_harness/src/lib.rs +++ b/rust/crates/loopbiotic_harness/src/lib.rs @@ -1,4 +1,3 @@ -pub mod actions; pub mod engine; pub mod session; pub mod state; diff --git a/rust/crates/loopbiotic_patch/Cargo.toml b/rust/crates/loopbiotic_patch/Cargo.toml index a460d2f..62ffbec 100644 --- a/rust/crates/loopbiotic_patch/Cargo.toml +++ b/rust/crates/loopbiotic_patch/Cargo.toml @@ -11,4 +11,3 @@ publish = false [dependencies] anyhow.workspace = true loopbiotic_protocol = { path = "../loopbiotic_protocol" } -thiserror.workspace = true From da07249d923f304801442acbfcfa933d5a2a8993 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:25:33 +0200 Subject: [PATCH 06/37] refactor: split context crate into index and rank modules --- rust/crates/loopbiotic_context/src/index.rs | 327 +++++ rust/crates/loopbiotic_context/src/lib.rs | 1220 +------------------ rust/crates/loopbiotic_context/src/rank.rs | 911 ++++++++++++++ 3 files changed, 1256 insertions(+), 1202 deletions(-) create mode 100644 rust/crates/loopbiotic_context/src/index.rs create mode 100644 rust/crates/loopbiotic_context/src/rank.rs diff --git a/rust/crates/loopbiotic_context/src/index.rs b/rust/crates/loopbiotic_context/src/index.rs new file mode 100644 index 0000000..640996b --- /dev/null +++ b/rust/crates/loopbiotic_context/src/index.rs @@ -0,0 +1,327 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Instant, UNIX_EPOCH}; + +use loopbiotic_protocol::ContextPolicy; + +use crate::rank::dependency_tokens; + +const DEFAULT_EXCLUDED_DIRS: &[&str] = &[ + ".git", + ".hg", + ".svn", + ".idea", + ".vscode", + "target", + "node_modules", + "vendor", + "dist", + "build", + "coverage", + "__pycache__", + ".cache", + ".angular", + ".nx", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + ".parcel-cache", + ".gradle", + ".dart_tool", + ".yarn", + ".pnpm-store", +]; + +pub(crate) const SOURCE_EXTENSIONS: &[&str] = &[ + "rs", + "lua", + "py", + "js", + "jsx", + "ts", + "tsx", + "go", + "java", + "kt", + "kts", + "c", + "h", + "cc", + "cpp", + "cxx", + "hpp", + "cs", + "rb", + "php", + "swift", + "scala", + "ex", + "exs", + "erl", + "hrl", + "fs", + "fsx", + "clj", + "cljs", + "vue", + "svelte", + "sql", + "sh", + "bash", + "zsh", + "fish", + "vim", + "nix", + "toml", + "yaml", + "yml", + "json", + "md", + "mdx", + "html", + "htm", + "css", + "scss", + "sass", + "less", + "xml", + "njk", + "jinja", + "jinja2", + "hbs", + "handlebars", + "tera", + "twig", + "ejs", + "mustache", + "astro", + "graphql", + "gql", +]; + +#[derive(Default)] +pub(crate) struct ProjectIndex { + pub(crate) files: HashMap, + pub(crate) last_refresh: Option, +} + +pub(crate) struct IndexedFile { + modified_ns: u128, + length: u64, + pub(crate) lines: Vec, + pub(crate) lower_lines: Vec, + pub(crate) dependencies: Vec, +} + +pub(crate) struct RefreshStats { + pub(crate) indexed_files: usize, + pub(crate) hits: usize, + pub(crate) misses: usize, +} + +impl ProjectIndex { + pub(crate) fn refresh(&mut self, root: &Path, policy: &ContextPolicy) -> RefreshStats { + if self + .last_refresh + .is_some_and(|refresh| refresh.elapsed().as_millis() < policy.cache_ttl_ms as u128) + { + return RefreshStats { + indexed_files: self.files.len(), + hits: self.files.len(), + misses: 0, + }; + } + let mut paths = Vec::new(); + collect_files(root, root, policy, &mut paths); + paths.sort(); + paths.truncate(policy.max_scan_files); + + let mut seen = HashSet::new(); + let mut hits = 0; + let mut misses = 0; + + for absolute in paths { + let Ok(relative) = absolute.strip_prefix(root).map(Path::to_path_buf) else { + continue; + }; + let Ok(metadata) = fs::metadata(&absolute) else { + continue; + }; + let modified_ns = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let unchanged = self.files.get(&relative).is_some_and(|cached| { + cached.modified_ns == modified_ns && cached.length == metadata.len() + }); + + seen.insert(relative.clone()); + if unchanged { + hits += 1; + continue; + } + + let Ok(text) = fs::read_to_string(&absolute) else { + continue; + }; + if text.contains('\0') { + continue; + } + let lines = text.lines().map(str::to_owned).collect::>(); + let lower_lines = lines.iter().map(|line| line.to_lowercase()).collect(); + let dependencies = dependency_tokens(&text); + self.files.insert( + relative, + IndexedFile { + modified_ns, + length: metadata.len(), + lines, + lower_lines, + dependencies, + }, + ); + misses += 1; + } + + self.files.retain(|path, _| seen.contains(path)); + self.last_refresh = Some(Instant::now()); + RefreshStats { + indexed_files: self.files.len(), + hits, + misses, + } + } +} + +fn collect_files(root: &Path, directory: &Path, policy: &ContextPolicy, files: &mut Vec) { + if files.len() >= policy.max_scan_files { + return; + } + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + let mut entries = entries.flatten().collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + if files.len() >= policy.max_scan_files { + break; + } + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() { + continue; + } + let relative = path.strip_prefix(root).unwrap_or(&path); + if excluded(relative, policy) { + continue; + } + if file_type.is_dir() { + collect_files(root, &path, policy, files); + } else if file_type.is_file() + && source_file(&path) + && entry + .metadata() + .is_ok_and(|metadata| metadata.len() <= policy.max_file_bytes as u64) + { + files.push(path); + } + } +} + +fn excluded(relative: &Path, policy: &ContextPolicy) -> bool { + let components = relative + .components() + .filter_map(|component| component.as_os_str().to_str()) + .collect::>(); + if components + .iter() + .any(|component| DEFAULT_EXCLUDED_DIRS.contains(component)) + { + return true; + } + let normalized = relative.to_string_lossy().replace('\\', "/"); + policy.exclude.iter().any(|pattern| { + let pattern = pattern.trim_matches('/'); + !pattern.is_empty() + && (normalized == pattern + || normalized.starts_with(&format!("{pattern}/")) + || normalized.contains(&format!("/{pattern}/"))) + }) +} + +fn source_file(path: &Path) -> bool { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + if matches!( + name, + "Dockerfile" | "Makefile" | "Rakefile" | "Gemfile" | "Justfile" | "CMakeLists.txt" + ) { + return true; + } + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + SOURCE_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str()) + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use loopbiotic_protocol::ContextPolicy; + + use crate::ContextOptimizer; + use crate::test_support::context; + + #[test] + fn excludes_angular_build_cache_before_the_scan_limit() { + let root = + std::env::temp_dir().join(format!("loopbiotic-context-angular-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".angular/cache/21/babel-webpack")).unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join(".angular/cache/21/babel-webpack/generated.json"), + r#"{"text":"preview_html preview_html preview_html"}"#, + ) + .unwrap(); + fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); + fs::write(root.join("src/preview.rs"), "pub fn preview_html() {}\n").unwrap(); + + let mut optimizer = ContextOptimizer::default(); + let optimized = optimizer.optimize( + context(&root, "fn main() {}\n"), + "Fix preview_html", + &ContextPolicy { + max_scan_files: 2, + ..ContextPolicy::default() + }, + ); + + assert_eq!(optimized.report.as_ref().unwrap().indexed_files, 2); + assert!( + optimized + .artifacts + .iter() + .all(|artifact| !artifact.file.starts_with(".angular")) + ); + assert!( + optimized + .artifacts + .iter() + .any(|artifact| artifact.file == Path::new("src/preview.rs")) + ); + let _ = fs::remove_dir_all(root); + } +} diff --git a/rust/crates/loopbiotic_context/src/lib.rs b/rust/crates/loopbiotic_context/src/lib.rs index a0c2657..f9b8794 100644 --- a/rust/crates/loopbiotic_context/src/lib.rs +++ b/rust/crates/loopbiotic_context/src/lib.rs @@ -1,254 +1,22 @@ +mod index; +mod rank; + use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; -use std::fs; use std::path::{Path, PathBuf}; -use std::time::{Instant, UNIX_EPOCH}; use loopbiotic_protocol::{ - ContextArtifact, ContextArtifactKind, ContextBundle, ContextCandidateReport, ContextHint, - ContextHintKind, ContextPolicy, ContextReport, + ContextArtifact, ContextBundle, ContextCandidateReport, ContextPolicy, ContextReport, }; -const DEFAULT_EXCLUDED_DIRS: &[&str] = &[ - ".git", - ".hg", - ".svn", - ".idea", - ".vscode", - "target", - "node_modules", - "vendor", - "dist", - "build", - "coverage", - "__pycache__", - ".cache", - ".angular", - ".nx", - ".next", - ".nuxt", - ".svelte-kit", - ".turbo", - ".parcel-cache", - ".gradle", - ".dart_tool", - ".yarn", - ".pnpm-store", -]; - -const SOURCE_EXTENSIONS: &[&str] = &[ - "rs", - "lua", - "py", - "js", - "jsx", - "ts", - "tsx", - "go", - "java", - "kt", - "kts", - "c", - "h", - "cc", - "cpp", - "cxx", - "hpp", - "cs", - "rb", - "php", - "swift", - "scala", - "ex", - "exs", - "erl", - "hrl", - "fs", - "fsx", - "clj", - "cljs", - "vue", - "svelte", - "sql", - "sh", - "bash", - "zsh", - "fish", - "vim", - "nix", - "toml", - "yaml", - "yml", - "json", - "md", - "mdx", - "html", - "htm", - "css", - "scss", - "sass", - "less", - "xml", - "njk", - "jinja", - "jinja2", - "hbs", - "handlebars", - "tera", - "twig", - "ejs", - "mustache", - "astro", - "graphql", - "gql", -]; - -const STOP_WORDS: &[&str] = &[ - "about", - "adjust", - "after", - "agent", - "async", - "await", - "before", - "boolean", - "change", - "class", - "code", - "const", - "could", - "create", - "current", - "def", - "enum", - "explain", - "file", - "fix", - "from", - "func", - "function", - "have", - "impl", - "into", - "just", - "make", - "more", - "mut", - "number", - "only", - "package", - "please", - "private", - "protected", - "pub", - "public", - "remove", - "replace", - "return", - "review", - "self", - "should", - "static", - "string", - "struct", - "that", - "the", - "their", - "then", - "there", - "this", - "update", - "void", - "with", - "would", - "your", - "błąd", - "czemu", - "dalej", - "dodaję", - "gdy", - "jest", - "jaki", - "mam", - "nie", - "oraz", - "resztę", - "samo", - "się", - "styl", - "żaden", - "dodaj", - "który", - "która", - "które", - "let", - "mamy", - "może", - "napraw", - "niech", - "plik", - "przez", - "tego", - "teraz", - "tylko", - "usuń", - "wyjaśnij", - "zmień", - "zrobić", - "zmiana", -]; +use index::ProjectIndex; +use rank::{CandidateQuery, dependency_tokens, query_paths, query_terms}; #[derive(Default)] pub struct ContextOptimizer { projects: HashMap, } -#[derive(Default)] -struct ProjectIndex { - files: HashMap, - last_refresh: Option, -} - -struct IndexedFile { - modified_ns: u128, - length: u64, - lines: Vec, - lower_lines: Vec, - dependencies: Vec, -} - -#[derive(Clone)] -struct QueryTerm { - value: String, - weight: i32, - source: &'static str, - document_frequency: usize, -} - -#[derive(Clone)] -struct QueryPath { - normalized: String, - basename: String, -} - -struct RefreshStats { - indexed_files: usize, - hits: usize, - misses: usize, -} - -struct CandidateQuery<'a> { - terms: &'a [QueryTerm], - paths: &'a [QueryPath], - current_file: Option<&'a Path>, - primary_start_line: usize, - primary_end_line: usize, - current_dependencies: &'a [String], - hints: &'a [ContextHint], - diagnostics: &'a [loopbiotic_protocol::Diagnostic], - root: &'a Path, -} - impl ContextOptimizer { pub fn optimize( &mut self, @@ -374,657 +142,6 @@ impl ContextOptimizer { } } -impl ProjectIndex { - fn refresh(&mut self, root: &Path, policy: &ContextPolicy) -> RefreshStats { - if self - .last_refresh - .is_some_and(|refresh| refresh.elapsed().as_millis() < policy.cache_ttl_ms as u128) - { - return RefreshStats { - indexed_files: self.files.len(), - hits: self.files.len(), - misses: 0, - }; - } - let mut paths = Vec::new(); - collect_files(root, root, policy, &mut paths); - paths.sort(); - paths.truncate(policy.max_scan_files); - - let mut seen = HashSet::new(); - let mut hits = 0; - let mut misses = 0; - - for absolute in paths { - let Ok(relative) = absolute.strip_prefix(root).map(Path::to_path_buf) else { - continue; - }; - let Ok(metadata) = fs::metadata(&absolute) else { - continue; - }; - let modified_ns = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - let unchanged = self.files.get(&relative).is_some_and(|cached| { - cached.modified_ns == modified_ns && cached.length == metadata.len() - }); - - seen.insert(relative.clone()); - if unchanged { - hits += 1; - continue; - } - - let Ok(text) = fs::read_to_string(&absolute) else { - continue; - }; - if text.contains('\0') { - continue; - } - let lines = text.lines().map(str::to_owned).collect::>(); - let lower_lines = lines.iter().map(|line| line.to_lowercase()).collect(); - let dependencies = dependency_tokens(&text); - self.files.insert( - relative, - IndexedFile { - modified_ns, - length: metadata.len(), - lines, - lower_lines, - dependencies, - }, - ); - misses += 1; - } - - self.files.retain(|path, _| seen.contains(path)); - self.last_refresh = Some(Instant::now()); - RefreshStats { - indexed_files: self.files.len(), - hits, - misses, - } - } - - fn candidates( - &self, - query: CandidateQuery<'_>, - policy: &ContextPolicy, - ) -> Vec { - let CandidateQuery { - terms, - paths, - current_file, - primary_start_line, - primary_end_line, - current_dependencies, - hints, - diagnostics, - root, - } = query; - let mut candidates = Vec::new(); - - for (path, file) in &self.files { - if file.lines.is_empty() { - continue; - } - - let is_current_file = current_file.is_some_and(|current| current == path); - let in_primary = |line_index: usize| { - let line = line_index + 1; - is_current_file && line >= primary_start_line && line <= primary_end_line - }; - - let path_lower = path.to_string_lossy().to_lowercase(); - let basename = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("") - .to_lowercase(); - let is_test = is_test_path(path); - let mut best: Option<(usize, i32, ContextArtifactKind, String)> = None; - let graph_distance = current_file - .and_then(|current| self.graph_distance(current, current_dependencies, path)); - - for query_path in paths { - let exact_path = path_lower == query_path.normalized - || path_lower.ends_with(&format!("/{}", query_path.normalized)); - let exact_basename = basename == query_path.basename; - if !exact_path && !exact_basename { - continue; - } - let line_index = first_query_line(file, terms) - .or_else(|| first_definition_line(file)) - .unwrap_or(0); - if in_primary(line_index) { - continue; - } - best = Some(( - line_index, - if exact_path { 340 } else { 300 }, - ContextArtifactKind::Reference, - format!("prompt path match: {}", query_path.normalized), - )); - } - - if let Some(distance) = graph_distance { - let line_index = dependency_line(file, current_file) - .or_else(|| first_definition_line(file)) - .unwrap_or(0); - let score = if distance == 1 { 95 } else { 48 }; - best = Some(( - line_index, - score, - ContextArtifactKind::Dependency, - format!("dependency graph distance {distance}"), - )); - } - - for hint in hints { - let Some(hint_file) = relative_file(root, &hint.file) else { - continue; - }; - if hint_file != *path || hint.line == 0 { - continue; - } - let line_index = hint.line.saturating_sub(1).min(file.lines.len() - 1); - if in_primary(line_index) { - continue; - } - let (score, kind) = match hint.kind { - ContextHintKind::Definition | ContextHintKind::Declaration => { - (240, ContextArtifactKind::Definition) - } - ContextHintKind::TypeDefinition => (225, ContextArtifactKind::Definition), - ContextHintKind::Implementation => (210, ContextArtifactKind::Definition), - ContextHintKind::Reference => (165, ContextArtifactKind::Reference), - }; - best = Some(( - line_index, - score, - kind, - format!("{} {:?} result", hint.source, hint.kind).to_lowercase(), - )); - } - - for diagnostic in diagnostics { - let Some(diagnostic_file) = relative_file(root, &diagnostic.file) else { - continue; - }; - if diagnostic_file != *path || diagnostic.line == 0 { - continue; - } - let line_index = diagnostic.line.saturating_sub(1).min(file.lines.len() - 1); - if in_primary(line_index) { - continue; - } - let severity_bonus = match diagnostic.severity.as_str() { - "1" | "error" | "Error" => 30, - "2" | "warning" | "Warning" => 15, - _ => 0, - }; - let score = 190 + severity_bonus; - if best - .as_ref() - .is_none_or(|(_, best_score, _, _)| score > *best_score) - { - best = Some(( - line_index, - score, - ContextArtifactKind::Diagnostic, - format!("diagnostic: {}", diagnostic.message), - )); - } - } - - for (line_index, line) in file.lower_lines.iter().enumerate() { - if in_primary(line_index) { - continue; - } - let mut score = 0; - let mut matched = Vec::new(); - let mut strongest_source = "context"; - - for term in terms { - if line_contains_term(line, &term.value) { - score += adjusted_term_weight(term, self.files.len()); - matched.push(term.value.as_str()); - strongest_source = term.source; - if is_definition_line(line, &term.value) { - score += definition_bonus(term, self.files.len()); - } - } - } - - if score == 0 { - continue; - } - if let Some(distance) = graph_distance { - score += if distance == 1 { 42 } else { 18 }; - } - - let definition = matched.iter().any(|term| is_definition_line(line, term)); - let dependency = looks_like_dependency(line); - let kind = if definition { - ContextArtifactKind::Definition - } else if is_test { - ContextArtifactKind::Test - } else if dependency { - ContextArtifactKind::Dependency - } else { - ContextArtifactKind::Reference - }; - if is_test { - score += 8; - } - if dependency { - score += 6; - } - let names = matched.into_iter().take(3).collect::>().join(", "); - let reason = format!("{strongest_source} match: {names}"); - - if best - .as_ref() - .is_none_or(|(_, best_score, _, _)| score > *best_score) - { - best = Some((line_index, score, kind, reason)); - } - } - - let Some((line_index, score, kind, reason)) = best else { - continue; - }; - let (start, end) = snippet_bounds(file.lines.len(), line_index, policy.snippet_lines); - let text = file.lines[start..end].join("\n"); - let estimated_tokens = estimate_tokens(&text) + estimate_tokens(&path_lower) + 12; - candidates.push(ContextArtifact { - file: path.clone(), - start_line: start + 1, - end_line: end, - kind, - reason, - text, - estimated_tokens, - score, - }); - } - - candidates - } - - fn apply_document_frequency(&self, terms: &mut [QueryTerm]) { - for term in terms { - term.document_frequency = self - .files - .iter() - .filter(|(path, file)| { - path.to_string_lossy().to_lowercase().contains(&term.value) - || file - .lower_lines - .iter() - .any(|line| line_contains_term(line, &term.value)) - }) - .count(); - } - } - - fn graph_distance( - &self, - current: &Path, - current_dependencies: &[String], - target: &Path, - ) -> Option { - let target_file = self.files.get(target)?; - if dependencies_match(current_dependencies, target) - || dependencies_match(&target_file.dependencies, current) - { - return Some(1); - } - - for (middle_path, middle_file) in &self.files { - if middle_path == current || middle_path == target { - continue; - } - let current_middle = dependencies_match(current_dependencies, middle_path) - || dependencies_match(&middle_file.dependencies, current); - if current_middle - && (dependencies_match(&middle_file.dependencies, target) - || dependencies_match(&target_file.dependencies, middle_path)) - { - return Some(2); - } - } - None - } -} - -fn dependency_tokens(text: &str) -> Vec { - let mut result = HashSet::new(); - for line in text - .lines() - .filter(|line| looks_like_dependency(&line.to_lowercase())) - { - let lower = line.to_lowercase(); - for token in identifiers(&lower) { - if !matches!( - token.as_str(), - "use" | "mod" | "pub" | "import" | "from" | "require" | "include" - ) { - result.insert(token); - } - } - for quoted in quoted_values(&lower) { - result.insert(quoted.replace(['.', ':', '\\'], "/")); - } - } - let mut result = result.into_iter().collect::>(); - result.sort(); - result -} - -fn quoted_values(text: &str) -> Vec { - let mut values = Vec::new(); - for quote in ['\'', '"'] { - let mut rest = text; - while let Some(start) = rest.find(quote) { - rest = &rest[start + quote.len_utf8()..]; - let Some(end) = rest.find(quote) else { - break; - }; - if end > 0 { - values.push(rest[..end].to_string()); - } - rest = &rest[end + quote.len_utf8()..]; - } - } - values -} - -fn dependencies_match(dependencies: &[String], path: &Path) -> bool { - let normalized = path.to_string_lossy().to_lowercase().replace('\\', "/"); - let without_extension = normalized - .rsplit_once('.') - .map(|(value, _)| value) - .unwrap_or(&normalized); - let stem = path - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or("") - .to_lowercase(); - dependencies.iter().any(|dependency| { - dependency == &stem - || without_extension.ends_with(dependency) - || without_extension.ends_with(&format!("/{dependency}")) - || dependency.ends_with(&format!("/{stem}")) - }) -} - -fn dependency_line(file: &IndexedFile, target: Option<&Path>) -> Option { - let target = target?; - let stem = target.file_stem()?.to_str()?.to_lowercase(); - file.lower_lines - .iter() - .position(|line| looks_like_dependency(line) && line.contains(&stem)) -} - -fn first_definition_line(file: &IndexedFile) -> Option { - file.lower_lines.iter().position(|line| { - let trimmed = line.trim_start(); - [ - "pub ", - "fn ", - "struct ", - "enum ", - "class ", - "def ", - "function ", - "interface ", - "type ", - "func ", - ] - .iter() - .any(|prefix| trimmed.starts_with(prefix)) - }) -} - -fn collect_files(root: &Path, directory: &Path, policy: &ContextPolicy, files: &mut Vec) { - if files.len() >= policy.max_scan_files { - return; - } - let Ok(entries) = fs::read_dir(directory) else { - return; - }; - let mut entries = entries.flatten().collect::>(); - entries.sort_by_key(|entry| entry.file_name()); - - for entry in entries { - if files.len() >= policy.max_scan_files { - break; - } - let path = entry.path(); - let Ok(file_type) = entry.file_type() else { - continue; - }; - if file_type.is_symlink() { - continue; - } - let relative = path.strip_prefix(root).unwrap_or(&path); - if excluded(relative, policy) { - continue; - } - if file_type.is_dir() { - collect_files(root, &path, policy, files); - } else if file_type.is_file() - && source_file(&path) - && entry - .metadata() - .is_ok_and(|metadata| metadata.len() <= policy.max_file_bytes as u64) - { - files.push(path); - } - } -} - -fn excluded(relative: &Path, policy: &ContextPolicy) -> bool { - let components = relative - .components() - .filter_map(|component| component.as_os_str().to_str()) - .collect::>(); - if components - .iter() - .any(|component| DEFAULT_EXCLUDED_DIRS.contains(component)) - { - return true; - } - let normalized = relative.to_string_lossy().replace('\\', "/"); - policy.exclude.iter().any(|pattern| { - let pattern = pattern.trim_matches('/'); - !pattern.is_empty() - && (normalized == pattern - || normalized.starts_with(&format!("{pattern}/")) - || normalized.contains(&format!("/{pattern}/"))) - }) -} - -fn source_file(path: &Path) -> bool { - let name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(""); - if matches!( - name, - "Dockerfile" | "Makefile" | "Rakefile" | "Gemfile" | "Justfile" | "CMakeLists.txt" - ) { - return true; - } - path.extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| { - SOURCE_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str()) - }) -} - -fn query_terms(context: &ContextBundle, prompt: &str) -> Vec { - let mut weights = HashMap::::new(); - add_terms(&mut weights, prompt, 14, "prompt"); - if let Some(selection) = &context.selection { - add_terms(&mut weights, &selection.text, 9, "selection"); - } - let cursor_offset = context - .cursor - .line - .saturating_sub(context.buffer_start_line); - let lines = context.buffer_text.lines().collect::>(); - let start = cursor_offset.saturating_sub(2); - let end = (cursor_offset + 3).min(lines.len()); - if start < end { - add_terms(&mut weights, &lines[start..end].join("\n"), 5, "cursor"); - } - - let mut terms = weights - .into_iter() - .map(|(value, (weight, source))| QueryTerm { - value, - weight, - source, - document_frequency: 0, - }) - .collect::>(); - terms.sort_by_key(|term| (Reverse(term.weight), Reverse(term.value.len()))); - terms.truncate(32); - terms -} - -fn query_paths(prompt: &str) -> Vec { - let mut paths = HashSet::new(); - for raw in prompt.split_whitespace() { - let normalized = raw - .trim_matches(|character: char| { - matches!( - character, - '`' | '\'' - | '"' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | ',' - | ';' - | ':' - | '?' - | '!' - | '.' - ) - }) - .replace('\\', "/") - .to_lowercase(); - let basename = normalized.rsplit('/').next().unwrap_or(&normalized); - let has_known_extension = basename - .rsplit_once('.') - .is_some_and(|(_, extension)| SOURCE_EXTENSIONS.contains(&extension)); - if has_known_extension { - paths.insert(normalized); - } - } - - let mut paths = paths - .into_iter() - .map(|normalized| QueryPath { - basename: normalized - .rsplit('/') - .next() - .unwrap_or(&normalized) - .to_string(), - normalized, - }) - .collect::>(); - paths.sort_by(|left, right| left.normalized.cmp(&right.normalized)); - paths -} - -fn adjusted_term_weight(term: &QueryTerm, file_count: usize) -> i32 { - let frequency = term.document_frequency.max(1); - let compound_prompt_bonus = - if term.source == "prompt" && (term.value.contains('_') || term.value.contains('-')) { - 20 - } else { - 0 - }; - let weight = if frequency == 1 { - term.weight + 18 - } else if frequency <= 3 { - term.weight + 12 - } else if frequency <= 8 { - term.weight + 5 - } else if frequency * 4 > file_count.max(1) { - (term.weight / 3).max(2) - } else { - term.weight - }; - weight + compound_prompt_bonus -} - -fn definition_bonus(term: &QueryTerm, file_count: usize) -> i32 { - let rare_limit = (file_count / 10).max(3); - if term.value.len() >= 5 && term.document_frequency <= rare_limit { - 55 - } else { - 12 - } -} - -fn first_query_line(file: &IndexedFile, terms: &[QueryTerm]) -> Option { - file.lower_lines.iter().position(|line| { - terms - .iter() - .any(|term| line_contains_term(line, &term.value)) - }) -} - -fn add_terms( - terms: &mut HashMap, - text: &str, - weight: i32, - source: &'static str, -) { - for term in identifiers(text) { - let entry = terms.entry(term).or_insert((weight, source)); - if weight > entry.0 { - *entry = (weight, source); - } - } -} - -fn identifiers(text: &str) -> Vec { - let mut result = Vec::new(); - let mut current = String::new(); - for character in text.chars() { - if character.is_alphanumeric() || character == '_' || character == '-' { - current.extend(character.to_lowercase()); - } else { - push_identifier(&mut result, &mut current); - } - } - push_identifier(&mut result, &mut current); - result -} - -fn push_identifier(result: &mut Vec, current: &mut String) { - if current.len() >= 3 - && !current.chars().all(|character| character.is_ascii_digit()) - && !STOP_WORDS.contains(¤t.as_str()) - { - result.push(std::mem::take(current)); - } else { - current.clear(); - } -} - fn trim_primary(context: &mut ContextBundle, budget: usize) -> bool { if estimate_tokens(&context.buffer_text) <= budget { return false; @@ -1076,71 +193,7 @@ fn trim_primary(context: &mut ContextBundle, budget: usize) -> bool { true } -fn snippet_bounds(total: usize, center: usize, requested: usize) -> (usize, usize) { - let count = requested.max(3).min(total); - let before = count / 2; - let mut start = center.saturating_sub(before); - let mut end = (start + count).min(total); - start = end.saturating_sub(count); - end = (start + count).min(total); - (start, end) -} - -fn is_definition_line(line: &str, term: &str) -> bool { - [ - "fn ", - "struct ", - "enum ", - "trait ", - "type ", - "class ", - "def ", - "function ", - "interface ", - "const ", - "let ", - "var ", - "func ", - "module ", - "pub ", - ] - .iter() - .any(|prefix| line.contains(&format!("{prefix}{term}"))) -} - -fn looks_like_dependency(line: &str) -> bool { - let trimmed = line.trim_start(); - [ - "use ", "mod ", "import ", "from ", "require(", "require ", "include ", "#include", - ] - .iter() - .any(|prefix| trimmed.starts_with(prefix)) -} - -fn line_contains_term(line: &str, term: &str) -> bool { - line.match_indices(term).any(|(start, _)| { - let before = line[..start].chars().next_back(); - let after = line[start + term.len()..].chars().next(); - let css_custom_property = line[..start].ends_with("--"); - (css_custom_property || before.is_none_or(|character| !is_identifier_character(character))) - && after.is_none_or(|character| !is_identifier_character(character)) - }) -} - -fn is_identifier_character(character: char) -> bool { - character.is_alphanumeric() || character == '_' || character == '-' -} - -fn is_test_path(path: &Path) -> bool { - let value = path.to_string_lossy().to_lowercase(); - value.contains("/test") - || value.contains("tests/") - || value.contains("_test.") - || value.contains(".test.") - || value.contains(".spec.") -} - -fn relative_file(root: &Path, file: &Path) -> Option { +pub(crate) fn relative_file(root: &Path, file: &Path) -> Option { if file.is_absolute() { file.strip_prefix(root).ok().map(Path::to_path_buf) } else { @@ -1163,14 +216,12 @@ pub fn estimate_tokens(text: &str) -> usize { } #[cfg(test)] -mod tests { - use std::fs; +pub(crate) mod test_support { + use std::path::{Path, PathBuf}; - use loopbiotic_protocol::{ContextBundle, ContextHint, ContextHintKind, ContextPolicy, Cursor}; - - use super::*; + use loopbiotic_protocol::{ContextBundle, Cursor}; - fn context(root: &Path, text: &str) -> ContextBundle { + pub(crate) fn context(root: &Path, text: &str) -> ContextBundle { ContextBundle { cwd: root.to_path_buf(), file: PathBuf::from("src/main.rs"), @@ -1184,40 +235,16 @@ mod tests { report: None, } } +} - #[test] - fn ranks_definitions_and_respects_budget() { - let root = std::env::temp_dir().join(format!("loopbiotic-context-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write( - root.join("src/main.rs"), - "fn main() {\n register_user();\n}\n", - ) - .unwrap(); - fs::write( - root.join("src/users.rs"), - "pub fn register_user() {\n validate_email();\n}\n", - ) - .unwrap(); - fs::write(root.join("src/noise.rs"), "pub fn unrelated() {}\n").unwrap(); +#[cfg(test)] +mod tests { + use std::fs; - let mut optimizer = ContextOptimizer::default(); - let optimized = optimizer.optimize( - context(&root, "fn main() {\n register_user();\n}\n"), - "Fix register_user validation", - &ContextPolicy { - total_token_budget: 1_200, - reserved_tokens: 200, - ..ContextPolicy::default() - }, - ); + use loopbiotic_protocol::ContextPolicy; - assert_eq!(optimized.artifacts[0].file, PathBuf::from("src/users.rs")); - assert_eq!(optimized.artifacts[0].kind, ContextArtifactKind::Definition); - assert!(optimized.report.unwrap().used_tokens <= 1_000); - let _ = fs::remove_dir_all(root); - } + use super::*; + use crate::test_support::context; #[test] fn truncates_primary_around_cursor() { @@ -1240,142 +267,6 @@ mod tests { assert!(optimized.report.unwrap().primary_truncated); } - #[test] - fn follows_dependency_graph_without_prompt_name_overlap() { - let root = - std::env::temp_dir().join(format!("loopbiotic-context-graph-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write(root.join("src/main.rs"), "mod users;\nfn main() {}\n").unwrap(); - fs::write(root.join("src/users.rs"), "pub fn register() {}\n").unwrap(); - - let mut optimizer = ContextOptimizer::default(); - let optimized = optimizer.optimize( - context(&root, "mod users;\nfn main() {}\n"), - "adjust the behavior", - &ContextPolicy::default(), - ); - - let users = optimized - .artifacts - .iter() - .find(|artifact| artifact.file == Path::new("src/users.rs")) - .expect("dependency should be selected"); - assert_eq!(users.kind, ContextArtifactKind::Dependency); - assert!(users.reason.contains("dependency graph")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn lsp_hint_outranks_textual_candidates_and_cache_is_reused() { - let root = - std::env::temp_dir().join(format!("loopbiotic-context-lsp-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write(root.join("src/main.go"), "package main\nfunc main() {}\n").unwrap(); - fs::write( - root.join("src/service.go"), - "package main\nfunc ResolveTarget() {}\n", - ) - .unwrap(); - fs::write( - root.join("src/noise.go"), - "package main\nfunc ResolveTargetNoise() {}\n", - ) - .unwrap(); - - let mut input = context(&root, "package main\nfunc main() {}\n"); - input.file = PathBuf::from("src/main.go"); - input.hints.push(ContextHint { - file: PathBuf::from("src/service.go"), - line: 2, - column: 1, - kind: ContextHintKind::Definition, - source: "gopls".into(), - }); - let mut optimizer = ContextOptimizer::default(); - let first = optimizer.optimize(input.clone(), "ResolveTarget", &ContextPolicy::default()); - let second = optimizer.optimize(input, "ResolveTarget", &ContextPolicy::default()); - - assert_eq!(first.artifacts[0].file, PathBuf::from("src/service.go")); - assert!(first.artifacts[0].reason.contains("gopls")); - let report = second.report.unwrap(); - assert!(report.cache_hits >= 3); - assert_eq!(report.cache_misses, 0); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn exact_template_path_outranks_common_html_symbols() { - let root = std::env::temp_dir().join(format!( - "loopbiotic-context-template-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(root.join("src")).unwrap(); - fs::create_dir_all(root.join("templates")).unwrap(); - fs::write( - root.join("src/main.rs"), - "fn html() -> String { String::new() }\nfn render_html() {}\n", - ) - .unwrap(); - fs::write( - root.join("src/noise.rs"), - "pub fn html() {}\npub fn another_html() {}\n", - ) - .unwrap(); - fs::write( - root.join("templates/layout_editor.html"), - "
{{ block.preview_html|safe }}
\n", - ) - .unwrap(); - - let mut optimizer = ContextOptimizer::default(); - let optimized = optimizer.optimize( - context(&root, "fn html() -> String { String::new() }\n"), - "Update templates/layout_editor.html to render the concrete preview", - &ContextPolicy::default(), - ); - - assert_eq!( - optimized.artifacts[0].file, - PathBuf::from("templates/layout_editor.html") - ); - assert!(optimized.artifacts[0].reason.contains("prompt path match")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rare_compound_prompt_symbol_selects_its_template_without_a_path() { - let root = std::env::temp_dir().join(format!( - "loopbiotic-context-template-symbol-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(root.join("src")).unwrap(); - fs::create_dir_all(root.join("templates/admin")).unwrap(); - fs::write(root.join("src/main.rs"), "fn html() {}\n").unwrap(); - fs::write( - root.join("templates/admin/layout_editor.html"), - "
{{ block.preview_html|safe }}
\n", - ) - .unwrap(); - - let mut optimizer = ContextOptimizer::default(); - let optimized = optimizer.optimize( - context(&root, "fn html() {}\n"), - "Replace preview_html with concrete structs for Askama templates", - &ContextPolicy::default(), - ); - - assert!( - optimized.artifacts.iter().any(|artifact| { - artifact.file == Path::new("templates/admin/layout_editor.html") - }) - ); - let _ = fs::remove_dir_all(root); - } - #[test] fn minimum_score_discards_weak_textual_noise() { let root = std::env::temp_dir().join(format!( @@ -1400,79 +291,4 @@ mod tests { assert!(optimized.artifacts.is_empty()); let _ = fs::remove_dir_all(root); } - - #[test] - fn excludes_angular_build_cache_before_the_scan_limit() { - let root = - std::env::temp_dir().join(format!("loopbiotic-context-angular-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(root.join(".angular/cache/21/babel-webpack")).unwrap(); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write( - root.join(".angular/cache/21/babel-webpack/generated.json"), - r#"{"text":"preview_html preview_html preview_html"}"#, - ) - .unwrap(); - fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); - fs::write(root.join("src/preview.rs"), "pub fn preview_html() {}\n").unwrap(); - - let mut optimizer = ContextOptimizer::default(); - let optimized = optimizer.optimize( - context(&root, "fn main() {}\n"), - "Fix preview_html", - &ContextPolicy { - max_scan_files: 2, - ..ContextPolicy::default() - }, - ); - - assert_eq!(optimized.report.as_ref().unwrap().indexed_files, 2); - assert!( - optimized - .artifacts - .iter() - .all(|artifact| !artifact.file.starts_with(".angular")) - ); - assert!( - optimized - .artifacts - .iter() - .any(|artifact| artifact.file == Path::new("src/preview.rs")) - ); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn selects_a_remote_definition_from_the_current_file() { - let root = std::env::temp_dir().join(format!( - "loopbiotic-context-same-file-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(root.join("src")).unwrap(); - let mut lines = vec!["// filler"; 80]; - lines[3] = "--text-vw-h3--font-weight: 800;"; - lines[70] = "h3 { @apply text-vw-h3 text-vw-h3--font-weight; }"; - fs::write(root.join("src/main.scss"), lines.join("\n")).unwrap(); - - let mut input = context(&root, "h3 { @apply text-vw-h3 text-vw-h3--font-weight; }"); - input.file = PathBuf::from("src/main.scss"); - input.buffer_start_line = 71; - input.cursor.line = 71; - let mut optimizer = ContextOptimizer::default(); - let optimized = optimizer.optimize( - input, - "Why does text-vw-h3--font-weight fail?", - &ContextPolicy::default(), - ); - - let remote = optimized - .artifacts - .iter() - .find(|artifact| artifact.file == Path::new("src/main.scss")) - .expect("remote definition from the current file should be selected"); - assert!(remote.start_line < 20); - assert!(remote.text.contains("--text-vw-h3--font-weight")); - let _ = fs::remove_dir_all(root); - } } diff --git a/rust/crates/loopbiotic_context/src/rank.rs b/rust/crates/loopbiotic_context/src/rank.rs new file mode 100644 index 0000000..40b01a6 --- /dev/null +++ b/rust/crates/loopbiotic_context/src/rank.rs @@ -0,0 +1,911 @@ +use std::cmp::Reverse; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use loopbiotic_protocol::{ + ContextArtifact, ContextArtifactKind, ContextBundle, ContextHint, ContextHintKind, + ContextPolicy, +}; + +use crate::index::{IndexedFile, ProjectIndex, SOURCE_EXTENSIONS}; +use crate::{estimate_tokens, relative_file}; + +const STOP_WORDS: &[&str] = &[ + "about", + "adjust", + "after", + "agent", + "async", + "await", + "before", + "boolean", + "change", + "class", + "code", + "const", + "could", + "create", + "current", + "def", + "enum", + "explain", + "file", + "fix", + "from", + "func", + "function", + "have", + "impl", + "into", + "just", + "make", + "more", + "mut", + "number", + "only", + "package", + "please", + "private", + "protected", + "pub", + "public", + "remove", + "replace", + "return", + "review", + "self", + "should", + "static", + "string", + "struct", + "that", + "the", + "their", + "then", + "there", + "this", + "update", + "void", + "with", + "would", + "your", + "błąd", + "czemu", + "dalej", + "dodaję", + "gdy", + "jest", + "jaki", + "mam", + "nie", + "oraz", + "resztę", + "samo", + "się", + "styl", + "żaden", + "dodaj", + "który", + "która", + "które", + "let", + "mamy", + "może", + "napraw", + "niech", + "plik", + "przez", + "tego", + "teraz", + "tylko", + "usuń", + "wyjaśnij", + "zmień", + "zrobić", + "zmiana", +]; + +#[derive(Clone)] +pub(crate) struct QueryTerm { + pub(crate) value: String, + pub(crate) weight: i32, + pub(crate) source: &'static str, + pub(crate) document_frequency: usize, +} + +#[derive(Clone)] +pub(crate) struct QueryPath { + pub(crate) normalized: String, + pub(crate) basename: String, +} + +pub(crate) struct CandidateQuery<'a> { + pub(crate) terms: &'a [QueryTerm], + pub(crate) paths: &'a [QueryPath], + pub(crate) current_file: Option<&'a Path>, + pub(crate) primary_start_line: usize, + pub(crate) primary_end_line: usize, + pub(crate) current_dependencies: &'a [String], + pub(crate) hints: &'a [ContextHint], + pub(crate) diagnostics: &'a [loopbiotic_protocol::Diagnostic], + pub(crate) root: &'a Path, +} + +impl ProjectIndex { + pub(crate) fn candidates( + &self, + query: CandidateQuery<'_>, + policy: &ContextPolicy, + ) -> Vec { + let CandidateQuery { + terms, + paths, + current_file, + primary_start_line, + primary_end_line, + current_dependencies, + hints, + diagnostics, + root, + } = query; + let mut candidates = Vec::new(); + + for (path, file) in &self.files { + if file.lines.is_empty() { + continue; + } + + let is_current_file = current_file.is_some_and(|current| current == path); + let in_primary = |line_index: usize| { + let line = line_index + 1; + is_current_file && line >= primary_start_line && line <= primary_end_line + }; + + let path_lower = path.to_string_lossy().to_lowercase(); + let basename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + .to_lowercase(); + let is_test = is_test_path(path); + let mut best: Option<(usize, i32, ContextArtifactKind, String)> = None; + let graph_distance = current_file + .and_then(|current| self.graph_distance(current, current_dependencies, path)); + + for query_path in paths { + let exact_path = path_lower == query_path.normalized + || path_lower.ends_with(&format!("/{}", query_path.normalized)); + let exact_basename = basename == query_path.basename; + if !exact_path && !exact_basename { + continue; + } + let line_index = first_query_line(file, terms) + .or_else(|| first_definition_line(file)) + .unwrap_or(0); + if in_primary(line_index) { + continue; + } + best = Some(( + line_index, + if exact_path { 340 } else { 300 }, + ContextArtifactKind::Reference, + format!("prompt path match: {}", query_path.normalized), + )); + } + + if let Some(distance) = graph_distance { + let line_index = dependency_line(file, current_file) + .or_else(|| first_definition_line(file)) + .unwrap_or(0); + let score = if distance == 1 { 95 } else { 48 }; + best = Some(( + line_index, + score, + ContextArtifactKind::Dependency, + format!("dependency graph distance {distance}"), + )); + } + + for hint in hints { + let Some(hint_file) = relative_file(root, &hint.file) else { + continue; + }; + if hint_file != *path || hint.line == 0 { + continue; + } + let line_index = hint.line.saturating_sub(1).min(file.lines.len() - 1); + if in_primary(line_index) { + continue; + } + let (score, kind) = match hint.kind { + ContextHintKind::Definition | ContextHintKind::Declaration => { + (240, ContextArtifactKind::Definition) + } + ContextHintKind::TypeDefinition => (225, ContextArtifactKind::Definition), + ContextHintKind::Implementation => (210, ContextArtifactKind::Definition), + ContextHintKind::Reference => (165, ContextArtifactKind::Reference), + }; + best = Some(( + line_index, + score, + kind, + format!("{} {:?} result", hint.source, hint.kind).to_lowercase(), + )); + } + + for diagnostic in diagnostics { + let Some(diagnostic_file) = relative_file(root, &diagnostic.file) else { + continue; + }; + if diagnostic_file != *path || diagnostic.line == 0 { + continue; + } + let line_index = diagnostic.line.saturating_sub(1).min(file.lines.len() - 1); + if in_primary(line_index) { + continue; + } + let severity_bonus = match diagnostic.severity.as_str() { + "1" | "error" | "Error" => 30, + "2" | "warning" | "Warning" => 15, + _ => 0, + }; + let score = 190 + severity_bonus; + if best + .as_ref() + .is_none_or(|(_, best_score, _, _)| score > *best_score) + { + best = Some(( + line_index, + score, + ContextArtifactKind::Diagnostic, + format!("diagnostic: {}", diagnostic.message), + )); + } + } + + for (line_index, line) in file.lower_lines.iter().enumerate() { + if in_primary(line_index) { + continue; + } + let mut score = 0; + let mut matched = Vec::new(); + let mut strongest_source = "context"; + + for term in terms { + if line_contains_term(line, &term.value) { + score += adjusted_term_weight(term, self.files.len()); + matched.push(term.value.as_str()); + strongest_source = term.source; + if is_definition_line(line, &term.value) { + score += definition_bonus(term, self.files.len()); + } + } + } + + if score == 0 { + continue; + } + if let Some(distance) = graph_distance { + score += if distance == 1 { 42 } else { 18 }; + } + + let definition = matched.iter().any(|term| is_definition_line(line, term)); + let dependency = looks_like_dependency(line); + let kind = if definition { + ContextArtifactKind::Definition + } else if is_test { + ContextArtifactKind::Test + } else if dependency { + ContextArtifactKind::Dependency + } else { + ContextArtifactKind::Reference + }; + if is_test { + score += 8; + } + if dependency { + score += 6; + } + let names = matched.into_iter().take(3).collect::>().join(", "); + let reason = format!("{strongest_source} match: {names}"); + + if best + .as_ref() + .is_none_or(|(_, best_score, _, _)| score > *best_score) + { + best = Some((line_index, score, kind, reason)); + } + } + + let Some((line_index, score, kind, reason)) = best else { + continue; + }; + let (start, end) = snippet_bounds(file.lines.len(), line_index, policy.snippet_lines); + let text = file.lines[start..end].join("\n"); + let estimated_tokens = estimate_tokens(&text) + estimate_tokens(&path_lower) + 12; + candidates.push(ContextArtifact { + file: path.clone(), + start_line: start + 1, + end_line: end, + kind, + reason, + text, + estimated_tokens, + score, + }); + } + + candidates + } + + pub(crate) fn apply_document_frequency(&self, terms: &mut [QueryTerm]) { + for term in terms { + term.document_frequency = self + .files + .iter() + .filter(|(path, file)| { + path.to_string_lossy().to_lowercase().contains(&term.value) + || file + .lower_lines + .iter() + .any(|line| line_contains_term(line, &term.value)) + }) + .count(); + } + } + + fn graph_distance( + &self, + current: &Path, + current_dependencies: &[String], + target: &Path, + ) -> Option { + let target_file = self.files.get(target)?; + if dependencies_match(current_dependencies, target) + || dependencies_match(&target_file.dependencies, current) + { + return Some(1); + } + + for (middle_path, middle_file) in &self.files { + if middle_path == current || middle_path == target { + continue; + } + let current_middle = dependencies_match(current_dependencies, middle_path) + || dependencies_match(&middle_file.dependencies, current); + if current_middle + && (dependencies_match(&middle_file.dependencies, target) + || dependencies_match(&target_file.dependencies, middle_path)) + { + return Some(2); + } + } + None + } +} + +pub(crate) fn dependency_tokens(text: &str) -> Vec { + let mut result = HashSet::new(); + for line in text + .lines() + .filter(|line| looks_like_dependency(&line.to_lowercase())) + { + let lower = line.to_lowercase(); + for token in identifiers(&lower) { + if !matches!( + token.as_str(), + "use" | "mod" | "pub" | "import" | "from" | "require" | "include" + ) { + result.insert(token); + } + } + for quoted in quoted_values(&lower) { + result.insert(quoted.replace(['.', ':', '\\'], "/")); + } + } + let mut result = result.into_iter().collect::>(); + result.sort(); + result +} + +fn quoted_values(text: &str) -> Vec { + let mut values = Vec::new(); + for quote in ['\'', '"'] { + let mut rest = text; + while let Some(start) = rest.find(quote) { + rest = &rest[start + quote.len_utf8()..]; + let Some(end) = rest.find(quote) else { + break; + }; + if end > 0 { + values.push(rest[..end].to_string()); + } + rest = &rest[end + quote.len_utf8()..]; + } + } + values +} + +fn dependencies_match(dependencies: &[String], path: &Path) -> bool { + let normalized = path.to_string_lossy().to_lowercase().replace('\\', "/"); + let without_extension = normalized + .rsplit_once('.') + .map(|(value, _)| value) + .unwrap_or(&normalized); + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("") + .to_lowercase(); + dependencies.iter().any(|dependency| { + dependency == &stem + || without_extension.ends_with(dependency) + || without_extension.ends_with(&format!("/{dependency}")) + || dependency.ends_with(&format!("/{stem}")) + }) +} + +fn dependency_line(file: &IndexedFile, target: Option<&Path>) -> Option { + let target = target?; + let stem = target.file_stem()?.to_str()?.to_lowercase(); + file.lower_lines + .iter() + .position(|line| looks_like_dependency(line) && line.contains(&stem)) +} + +fn first_definition_line(file: &IndexedFile) -> Option { + file.lower_lines.iter().position(|line| { + let trimmed = line.trim_start(); + [ + "pub ", + "fn ", + "struct ", + "enum ", + "class ", + "def ", + "function ", + "interface ", + "type ", + "func ", + ] + .iter() + .any(|prefix| trimmed.starts_with(prefix)) + }) +} + +pub(crate) fn query_terms(context: &ContextBundle, prompt: &str) -> Vec { + let mut weights = HashMap::::new(); + add_terms(&mut weights, prompt, 14, "prompt"); + if let Some(selection) = &context.selection { + add_terms(&mut weights, &selection.text, 9, "selection"); + } + let cursor_offset = context + .cursor + .line + .saturating_sub(context.buffer_start_line); + let lines = context.buffer_text.lines().collect::>(); + let start = cursor_offset.saturating_sub(2); + let end = (cursor_offset + 3).min(lines.len()); + if start < end { + add_terms(&mut weights, &lines[start..end].join("\n"), 5, "cursor"); + } + + let mut terms = weights + .into_iter() + .map(|(value, (weight, source))| QueryTerm { + value, + weight, + source, + document_frequency: 0, + }) + .collect::>(); + terms.sort_by_key(|term| (Reverse(term.weight), Reverse(term.value.len()))); + terms.truncate(32); + terms +} + +pub(crate) fn query_paths(prompt: &str) -> Vec { + let mut paths = HashSet::new(); + for raw in prompt.split_whitespace() { + let normalized = raw + .trim_matches(|character: char| { + matches!( + character, + '`' | '\'' + | '"' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | ',' + | ';' + | ':' + | '?' + | '!' + | '.' + ) + }) + .replace('\\', "/") + .to_lowercase(); + let basename = normalized.rsplit('/').next().unwrap_or(&normalized); + let has_known_extension = basename + .rsplit_once('.') + .is_some_and(|(_, extension)| SOURCE_EXTENSIONS.contains(&extension)); + if has_known_extension { + paths.insert(normalized); + } + } + + let mut paths = paths + .into_iter() + .map(|normalized| QueryPath { + basename: normalized + .rsplit('/') + .next() + .unwrap_or(&normalized) + .to_string(), + normalized, + }) + .collect::>(); + paths.sort_by(|left, right| left.normalized.cmp(&right.normalized)); + paths +} + +fn adjusted_term_weight(term: &QueryTerm, file_count: usize) -> i32 { + let frequency = term.document_frequency.max(1); + let compound_prompt_bonus = + if term.source == "prompt" && (term.value.contains('_') || term.value.contains('-')) { + 20 + } else { + 0 + }; + let weight = if frequency == 1 { + term.weight + 18 + } else if frequency <= 3 { + term.weight + 12 + } else if frequency <= 8 { + term.weight + 5 + } else if frequency * 4 > file_count.max(1) { + (term.weight / 3).max(2) + } else { + term.weight + }; + weight + compound_prompt_bonus +} + +fn definition_bonus(term: &QueryTerm, file_count: usize) -> i32 { + let rare_limit = (file_count / 10).max(3); + if term.value.len() >= 5 && term.document_frequency <= rare_limit { + 55 + } else { + 12 + } +} + +fn first_query_line(file: &IndexedFile, terms: &[QueryTerm]) -> Option { + file.lower_lines.iter().position(|line| { + terms + .iter() + .any(|term| line_contains_term(line, &term.value)) + }) +} + +fn add_terms( + terms: &mut HashMap, + text: &str, + weight: i32, + source: &'static str, +) { + for term in identifiers(text) { + let entry = terms.entry(term).or_insert((weight, source)); + if weight > entry.0 { + *entry = (weight, source); + } + } +} + +fn identifiers(text: &str) -> Vec { + let mut result = Vec::new(); + let mut current = String::new(); + for character in text.chars() { + if character.is_alphanumeric() || character == '_' || character == '-' { + current.extend(character.to_lowercase()); + } else { + push_identifier(&mut result, &mut current); + } + } + push_identifier(&mut result, &mut current); + result +} + +fn push_identifier(result: &mut Vec, current: &mut String) { + if current.len() >= 3 + && !current.chars().all(|character| character.is_ascii_digit()) + && !STOP_WORDS.contains(¤t.as_str()) + { + result.push(std::mem::take(current)); + } else { + current.clear(); + } +} + +fn snippet_bounds(total: usize, center: usize, requested: usize) -> (usize, usize) { + let count = requested.max(3).min(total); + let before = count / 2; + let mut start = center.saturating_sub(before); + let mut end = (start + count).min(total); + start = end.saturating_sub(count); + end = (start + count).min(total); + (start, end) +} + +fn is_definition_line(line: &str, term: &str) -> bool { + [ + "fn ", + "struct ", + "enum ", + "trait ", + "type ", + "class ", + "def ", + "function ", + "interface ", + "const ", + "let ", + "var ", + "func ", + "module ", + "pub ", + ] + .iter() + .any(|prefix| line.contains(&format!("{prefix}{term}"))) +} + +fn looks_like_dependency(line: &str) -> bool { + let trimmed = line.trim_start(); + [ + "use ", "mod ", "import ", "from ", "require(", "require ", "include ", "#include", + ] + .iter() + .any(|prefix| trimmed.starts_with(prefix)) +} + +fn line_contains_term(line: &str, term: &str) -> bool { + line.match_indices(term).any(|(start, _)| { + let before = line[..start].chars().next_back(); + let after = line[start + term.len()..].chars().next(); + let css_custom_property = line[..start].ends_with("--"); + (css_custom_property || before.is_none_or(|character| !is_identifier_character(character))) + && after.is_none_or(|character| !is_identifier_character(character)) + }) +} + +fn is_identifier_character(character: char) -> bool { + character.is_alphanumeric() || character == '_' || character == '-' +} + +fn is_test_path(path: &Path) -> bool { + let value = path.to_string_lossy().to_lowercase(); + value.contains("/test") + || value.contains("tests/") + || value.contains("_test.") + || value.contains(".test.") + || value.contains(".spec.") +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use loopbiotic_protocol::{ContextArtifactKind, ContextHint, ContextHintKind, ContextPolicy}; + + use crate::ContextOptimizer; + use crate::test_support::context; + + #[test] + fn ranks_definitions_and_respects_budget() { + let root = std::env::temp_dir().join(format!("loopbiotic-context-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("src/main.rs"), + "fn main() {\n register_user();\n}\n", + ) + .unwrap(); + fs::write( + root.join("src/users.rs"), + "pub fn register_user() {\n validate_email();\n}\n", + ) + .unwrap(); + fs::write(root.join("src/noise.rs"), "pub fn unrelated() {}\n").unwrap(); + + let mut optimizer = ContextOptimizer::default(); + let optimized = optimizer.optimize( + context(&root, "fn main() {\n register_user();\n}\n"), + "Fix register_user validation", + &ContextPolicy { + total_token_budget: 1_200, + reserved_tokens: 200, + ..ContextPolicy::default() + }, + ); + + assert_eq!(optimized.artifacts[0].file, PathBuf::from("src/users.rs")); + assert_eq!(optimized.artifacts[0].kind, ContextArtifactKind::Definition); + assert!(optimized.report.unwrap().used_tokens <= 1_000); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn follows_dependency_graph_without_prompt_name_overlap() { + let root = + std::env::temp_dir().join(format!("loopbiotic-context-graph-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/main.rs"), "mod users;\nfn main() {}\n").unwrap(); + fs::write(root.join("src/users.rs"), "pub fn register() {}\n").unwrap(); + + let mut optimizer = ContextOptimizer::default(); + let optimized = optimizer.optimize( + context(&root, "mod users;\nfn main() {}\n"), + "adjust the behavior", + &ContextPolicy::default(), + ); + + let users = optimized + .artifacts + .iter() + .find(|artifact| artifact.file == Path::new("src/users.rs")) + .expect("dependency should be selected"); + assert_eq!(users.kind, ContextArtifactKind::Dependency); + assert!(users.reason.contains("dependency graph")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn lsp_hint_outranks_textual_candidates_and_cache_is_reused() { + let root = + std::env::temp_dir().join(format!("loopbiotic-context-lsp-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/main.go"), "package main\nfunc main() {}\n").unwrap(); + fs::write( + root.join("src/service.go"), + "package main\nfunc ResolveTarget() {}\n", + ) + .unwrap(); + fs::write( + root.join("src/noise.go"), + "package main\nfunc ResolveTargetNoise() {}\n", + ) + .unwrap(); + + let mut input = context(&root, "package main\nfunc main() {}\n"); + input.file = PathBuf::from("src/main.go"); + input.hints.push(ContextHint { + file: PathBuf::from("src/service.go"), + line: 2, + column: 1, + kind: ContextHintKind::Definition, + source: "gopls".into(), + }); + let mut optimizer = ContextOptimizer::default(); + let first = optimizer.optimize(input.clone(), "ResolveTarget", &ContextPolicy::default()); + let second = optimizer.optimize(input, "ResolveTarget", &ContextPolicy::default()); + + assert_eq!(first.artifacts[0].file, PathBuf::from("src/service.go")); + assert!(first.artifacts[0].reason.contains("gopls")); + let report = second.report.unwrap(); + assert!(report.cache_hits >= 3); + assert_eq!(report.cache_misses, 0); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn exact_template_path_outranks_common_html_symbols() { + let root = std::env::temp_dir().join(format!( + "loopbiotic-context-template-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("src")).unwrap(); + fs::create_dir_all(root.join("templates")).unwrap(); + fs::write( + root.join("src/main.rs"), + "fn html() -> String { String::new() }\nfn render_html() {}\n", + ) + .unwrap(); + fs::write( + root.join("src/noise.rs"), + "pub fn html() {}\npub fn another_html() {}\n", + ) + .unwrap(); + fs::write( + root.join("templates/layout_editor.html"), + "
{{ block.preview_html|safe }}
\n", + ) + .unwrap(); + + let mut optimizer = ContextOptimizer::default(); + let optimized = optimizer.optimize( + context(&root, "fn html() -> String { String::new() }\n"), + "Update templates/layout_editor.html to render the concrete preview", + &ContextPolicy::default(), + ); + + assert_eq!( + optimized.artifacts[0].file, + PathBuf::from("templates/layout_editor.html") + ); + assert!(optimized.artifacts[0].reason.contains("prompt path match")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rare_compound_prompt_symbol_selects_its_template_without_a_path() { + let root = std::env::temp_dir().join(format!( + "loopbiotic-context-template-symbol-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("src")).unwrap(); + fs::create_dir_all(root.join("templates/admin")).unwrap(); + fs::write(root.join("src/main.rs"), "fn html() {}\n").unwrap(); + fs::write( + root.join("templates/admin/layout_editor.html"), + "
{{ block.preview_html|safe }}
\n", + ) + .unwrap(); + + let mut optimizer = ContextOptimizer::default(); + let optimized = optimizer.optimize( + context(&root, "fn html() {}\n"), + "Replace preview_html with concrete structs for Askama templates", + &ContextPolicy::default(), + ); + + assert!( + optimized.artifacts.iter().any(|artifact| { + artifact.file == Path::new("templates/admin/layout_editor.html") + }) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn selects_a_remote_definition_from_the_current_file() { + let root = std::env::temp_dir().join(format!( + "loopbiotic-context-same-file-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("src")).unwrap(); + let mut lines = vec!["// filler"; 80]; + lines[3] = "--text-vw-h3--font-weight: 800;"; + lines[70] = "h3 { @apply text-vw-h3 text-vw-h3--font-weight; }"; + fs::write(root.join("src/main.scss"), lines.join("\n")).unwrap(); + + let mut input = context(&root, "h3 { @apply text-vw-h3 text-vw-h3--font-weight; }"); + input.file = PathBuf::from("src/main.scss"); + input.buffer_start_line = 71; + input.cursor.line = 71; + let mut optimizer = ContextOptimizer::default(); + let optimized = optimizer.optimize( + input, + "Why does text-vw-h3--font-weight fail?", + &ContextPolicy::default(), + ); + + let remote = optimized + .artifacts + .iter() + .find(|artifact| artifact.file == Path::new("src/main.scss")) + .expect("remote definition from the current file should be selected"); + assert!(remote.start_line < 20); + assert!(remote.text.contains("--text-vw-h3--font-weight")); + let _ = fs::remove_dir_all(root); + } +} From f83b5be6a71609bcc1fd450ecb5073a9770744e3 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:36:46 +0200 Subject: [PATCH 07/37] fix: fail pending RPC callbacks on stop and guard channel sends --- lua/loopbiotic/rpc.lua | 74 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/lua/loopbiotic/rpc.lua b/lua/loopbiotic/rpc.lua index 81a7fbe..d79975f 100644 --- a/lua/loopbiotic/rpc.lua +++ b/lua/loopbiotic/rpc.lua @@ -16,6 +16,27 @@ local M = { } local protocol_version = require("loopbiotic.version").protocol +-- Send one JSON-RPC line to the backend. Callers can run from scheduled +-- callbacks after on_exit already cleared the job, so a missing or closed +-- channel is logged instead of thrown. +---@param payload string encoded JSON-RPC message, without trailing newline +---@return boolean sent +local function try_send(payload) + if not M.job then + log.event("rpc_send_dropped", { reason = "backend is not running" }) + return false + end + + local ok, sent = pcall(vim.fn.chansend, M.job, payload .. "\n") + if not ok or sent == 0 then + log.event("rpc_send_dropped", { reason = ok and "channel closed" or tostring(sent) }) + return false + end + + return true +end + +-- Start the backend if it is not already running or starting. function M.ensure() if M.job and vim.fn.jobwait({ M.job }, 0)[1] == -1 then return @@ -43,6 +64,8 @@ function M.ensure() end) end +-- Spawn the backend process and perform the protocol handshake. +---@param backend_command string function M.start(backend_command) if M.job and vim.fn.jobwait({ M.job }, 0)[1] == -1 then return @@ -123,6 +146,8 @@ function M.start(backend_command) end) end +-- Stop the backend. In-flight and queued requests are failed with an aborted +-- error (like on_exit) so their callers can settle instead of waiting forever. function M.stop() M.generation = M.generation + 1 if M.job and vim.fn.jobwait({ M.job }, 0)[1] == -1 then @@ -133,11 +158,16 @@ function M.stop() M.ready = false M.incompatible = false M.starting = false - M.queue = {} - M.pending = {} M.buffer = "" + M.fail_all("Loopbiotic backend was stopped") end +-- Send a request, starting and queueing on the backend when it is not ready +-- yet. The callback always receives a response table ({ result = ... } or +-- { error = ... }). +---@param method string +---@param params table|nil +---@param callback fun(message: table) function M.request(method, params, callback) if M.incompatible then callback({ @@ -162,8 +192,10 @@ function M.request(method, params, callback) M.send(method, params, callback) end +---@param method string +---@param params table|nil +---@param callback fun(message: table) function M.send(method, params, callback) - local id = M.next_id M.next_id = M.next_id + 1 M.pending[id] = callback @@ -180,9 +212,20 @@ function M.send(method, params, callback) method = method, params = params or {}, }) - vim.fn.chansend(M.job, payload .. "\n") + if not try_send(payload) then + M.pending[id] = nil + callback({ + error = { + code = -32098, + message = "Loopbiotic backend is not running", + }, + }) + end end +-- Fail every queued and pending request with one aborted-error response. +---@param message string +---@return integer failed how many callbacks were invoked function M.fail_all(message) local response = { error = { @@ -207,17 +250,25 @@ function M.fail_all(message) return failed end +-- Register a handler for a backend notification (method without id). +---@param method string +---@param callback fun(params: table) function M.on(method, callback) M.notifications[method] = callback end -- Handlers for requests initiated by loopbioticd (method + id). The handler -- receives (params, respond); it must call respond(result) exactly once. +---@param method string +---@param callback fun(params: table, respond: fun(result: table)) function M.on_request(method, callback) M.requests = M.requests or {} M.requests[method] = callback end +-- Answer a backend-initiated request. +---@param id integer|string +---@param result table function M.respond(id, result) local payload = vim.json.encode({ jsonrpc = "2.0", @@ -226,7 +277,7 @@ function M.respond(id, result) }) log.event("rpc_server_response", { id = id }) - vim.fn.chansend(M.job, payload .. "\n") + try_send(payload) end function M.on_data(data) @@ -272,14 +323,11 @@ function M.handle(line) local id = message.id if not handler then - vim.fn.chansend( - M.job, - vim.json.encode({ - jsonrpc = "2.0", - id = id, - error = { code = -32601, message = "unknown editor request " .. message.method }, - }) .. "\n" - ) + try_send(vim.json.encode({ + jsonrpc = "2.0", + id = id, + error = { code = -32601, message = "unknown editor request " .. message.method }, + })) return end From b7296a638aecba0d85ea1e9838f70db258182d0d Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:36:46 +0200 Subject: [PATCH 08/37] fix: bound installer downloads and extract only the daemon binary --- lua/loopbiotic/installer.lua | 38 ++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/lua/loopbiotic/installer.lua b/lua/loopbiotic/installer.lua index 8aecc4a..3e3b2b2 100644 --- a/lua/loopbiotic/installer.lua +++ b/lua/loopbiotic/installer.lua @@ -29,8 +29,7 @@ function M.target() end function M.version() - return (config.values.distribution and config.values.distribution.version) - or require("loopbiotic.version").plugin + return (config.values.distribution and config.values.distribution.version) or require("loopbiotic.version").plugin end function M.artifact(target) @@ -76,7 +75,8 @@ function M.resolve(callback) end return end - if (not distribution.repository or distribution.repository == "") + if + (not distribution.repository or distribution.repository == "") and (not distribution.base_url or distribution.base_url == "") then if vim.fn.executable("loopbioticd") == 1 then @@ -124,12 +124,36 @@ function M.download(target, callback) vim.fn.delete(temporary, "rf") vim.fn.mkdir(temporary, "p") - M.run({ "curl", "-fL", "--retry", "2", "-o", checksums, base .. "/checksums.txt" }, function(first) + M.run({ + "curl", + "-fL", + "--retry", + "2", + "--connect-timeout", + "10", + "--max-time", + "60", + "-o", + checksums, + base .. "/checksums.txt", + }, function(first) if first.code ~= 0 then callback(nil, "could not download loopbioticd checksums: " .. M.error_text(first)) return end - M.run({ "curl", "-fL", "--retry", "2", "-o", archive, base .. "/" .. artifact }, function(second) + M.run({ + "curl", + "-fL", + "--retry", + "2", + "--connect-timeout", + "10", + "--max-time", + "300", + "-o", + archive, + base .. "/" .. artifact, + }, function(second) if second.code ~= 0 then callback(nil, "could not download loopbioticd: " .. M.error_text(second)) return @@ -150,7 +174,9 @@ function M.download(target, callback) end local directory = M.install_dir(target) vim.fn.mkdir(directory, "p") - M.run({ "tar", "-xzf", archive, "-C", directory }, function(extracted) + -- Extract only the expected binary so an unexpected archive cannot + -- drop other files into the install directory. + M.run({ "tar", "-xzf", archive, "-C", directory, "loopbioticd" }, function(extracted) vim.fn.delete(temporary, "rf") if extracted.code ~= 0 then callback(nil, "could not extract loopbioticd: " .. M.error_text(extracted)) From 12f296b49590c14b26b43c0e3d3821b4e267288b Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:36:46 +0200 Subject: [PATCH 09/37] refactor: centralize turn results, state reset, and shared Lua helpers --- lua/loopbiotic/card.lua | 61 ++++++++------ lua/loopbiotic/context.lua | 17 ++-- lua/loopbiotic/diff.lua | 34 ++++---- lua/loopbiotic/init.lua | 112 ++++++------------------- lua/loopbiotic/navigation.lua | 11 +-- lua/loopbiotic/session.lua | 28 +++++++ lua/loopbiotic/state.lua | 154 +++++++++++++++++++++++++--------- lua/loopbiotic/util.lua | 52 ++++++++++++ 8 files changed, 282 insertions(+), 187 deletions(-) create mode 100644 lua/loopbiotic/session.lua create mode 100644 lua/loopbiotic/util.lua diff --git a/lua/loopbiotic/card.lua b/lua/loopbiotic/card.lua index c1b563f..fab74a8 100644 --- a/lua/loopbiotic/card.lua +++ b/lua/loopbiotic/card.lua @@ -3,6 +3,29 @@ local navigation = require("loopbiotic.navigation") local state = require("loopbiotic.state") local status = require("loopbiotic.status") local ui = require("loopbiotic.ui") +local util = require("loopbiotic.util") + +---@class LoopbioticCard a card proposed by the backend +---@field id string +---@field kind string "hypothesis" | "finding" | "patch" | "summary" | "error" | "deny" | "choice" +---@field title? string +---@field actions? (string|table)[] available actions; tables carry apply_patch payloads +---@field next_actions? (string|table)[] legacy name for actions +---@field location? table { file, line, column, annotation? } +---@field evidence? table location-shaped evidence (hypothesis cards) +---@field next_move? table { kind = "open_location", file, line, column } +---@field claim? string hypothesis cards +---@field finding? string finding cards +---@field annotation? string finding cards +---@field explanation? string patch cards +---@field patches? { id: string, file: string, diff: string }[] patch cards +---@field warnings? string[] patch cards +---@field changed_files? string[] +---@field summary? string summary cards +---@field message? string error cards +---@field reason? string deny cards +---@field question? string choice cards +---@field options? { id?: string, label?: string }[] choice cards local M = {} @@ -77,10 +100,7 @@ function M.show(card, opts) state.completion_notified_card = card.id ui.notify("Local step applied. Assess the goal, run checks, or stop.") end - if card.kind == "summary" - and card.title == "Goal complete" - and state.completion_checked_card ~= card.id - then + if card.kind == "summary" and card.title == "Goal complete" and state.completion_checked_card ~= card.id then state.completion_checked_card = card.id vim.defer_fn(function() if state.card == card then @@ -202,11 +222,7 @@ function M.observation_network(lines, observations) end function M.observation_node(observation, index) - local kind = observation.kind == "hypothesis" and "H" or observation.kind == "signal" and "S" or "F" - local active = observation.active and "*" or "." - local repeats = (observation.occurrences or 1) > 1 and "x" .. observation.occurrences or "" - - return string.format("[%s%d%s%s]", kind, index, active, repeats) + return util.observation_node(observation, index) end function M.short(text, limit) @@ -252,17 +268,7 @@ function M.toggle_details(card) end function M.location(card) - if type(card.next_move) == "table" and card.next_move.kind == "open_location" then - return card.next_move - end - if type(card.evidence) == "table" then - return card.evidence - end - if type(card.location) == "table" then - return card.location - end - - return nil + return util.card_location(card) end function M.add(lines, text) @@ -321,12 +327,15 @@ function M.tokens(lines) local report = state.context_report if report and report.enabled then - table.insert(lines, string.format( - "Context %s/%s · %s fragments", - report.used_tokens or 0, - report.budget_tokens or 0, - report.selected_count or 0 - )) + table.insert( + lines, + string.format( + "Context %s/%s · %s fragments", + report.used_tokens or 0, + report.budget_tokens or 0, + report.selected_count or 0 + ) + ) end end diff --git a/lua/loopbiotic/context.lua b/lua/loopbiotic/context.lua index 7b72746..282161f 100644 --- a/lua/loopbiotic/context.lua +++ b/lua/loopbiotic/context.lua @@ -1,5 +1,6 @@ local config = require("loopbiotic.config") local selection = require("loopbiotic.selection") +local util = require("loopbiotic.util") local M = {} @@ -101,7 +102,8 @@ function M.lsp_hints(buf, cursor, cwd) return {} end - local clients = vim.lsp.get_clients and vim.lsp.get_clients({ bufnr = buf }) or vim.lsp.get_active_clients({ bufnr = buf }) + local clients = vim.lsp.get_clients and vim.lsp.get_clients({ bufnr = buf }) + or vim.lsp.get_active_clients({ bufnr = buf }) local methods = { { enabled = options.definition, method = "textDocument/definition", kind = "definition" }, { enabled = options.declaration, method = "textDocument/declaration", kind = "declaration" }, @@ -154,7 +156,8 @@ function M.workspace_hints(prompt, cwd, buf) return {} end - local clients = vim.lsp.get_clients and vim.lsp.get_clients({ bufnr = buf }) or vim.lsp.get_active_clients({ bufnr = buf }) + local clients = vim.lsp.get_clients and vim.lsp.get_clients({ bufnr = buf }) + or vim.lsp.get_active_clients({ bufnr = buf }) local queries = M.workspace_queries(prompt, options.max_workspace_queries or 3) local hints = {} local seen = {} @@ -199,7 +202,7 @@ function M.workspace_queries(prompt, limit) potem = true, rzecz = true, struct = true, - structów = true, + ["structów"] = true, template = true, } local weighted = {} @@ -260,9 +263,7 @@ function M.add_lsp_locations(hints, seen, result, kind, source, limit, cwd) local range = target.targetSelectionRange or target.targetRange or target.range if uri and range and range.start then local ok, filename = pcall(vim.uri_to_fname, uri) - local root = vim.fn.fnamemodify(cwd or vim.fn.getcwd(), ":p"):gsub("/$", "") - local absolute = ok and vim.fn.fnamemodify(filename, ":p") or "" - if ok and (absolute == root or absolute:sub(1, #root + 1) == root .. "/") then + if ok and util.in_workspace(filename, cwd) then local file = vim.fn.fnamemodify(filename, ":.") local line = range.start.line + 1 local column = range.start.character + 1 @@ -334,11 +335,9 @@ function M.diagnostics(file, buf) local items = {} local limit = config.values.context.max_diagnostics local buffers = { buf or 0 } - local root = vim.fn.fnamemodify(vim.fn.getcwd(), ":p"):gsub("/$", "") for _, candidate in ipairs(vim.api.nvim_list_bufs()) do local candidate_file = vim.api.nvim_buf_get_name(candidate) - local absolute = candidate_file ~= "" and vim.fn.fnamemodify(candidate_file, ":p") or "" - local in_project = absolute == root or absolute:sub(1, #root + 1) == root .. "/" + local in_project = util.in_workspace(candidate_file) if candidate ~= buf and vim.api.nvim_buf_is_loaded(candidate) and in_project then table.insert(buffers, candidate) end diff --git a/lua/loopbiotic/diff.lua b/lua/loopbiotic/diff.lua index 08713eb..77e6a3b 100644 --- a/lua/loopbiotic/diff.lua +++ b/lua/loopbiotic/diff.lua @@ -3,9 +3,11 @@ local context = require("loopbiotic.context") local log = require("loopbiotic.log") local navigation = require("loopbiotic.navigation") local rpc = require("loopbiotic.rpc") +local session = require("loopbiotic.session") local state = require("loopbiotic.state") local thinking = require("loopbiotic.thinking") local ui = require("loopbiotic.ui") +local util = require("loopbiotic.util") local M = {} local namespace = vim.api.nvim_create_namespace("loopbiotic-patch") @@ -55,11 +57,13 @@ function M.show(card, opts) local annotations = M.annotations(hunk, source_start) local change_cursor = M.change_cursor(draft_lines, annotations) - if not navigation.open_location({ - file = patch.file, - line = change_cursor[1], - column = change_cursor[2] + 1, - }) then + if + not navigation.open_location({ + file = patch.file, + line = change_cursor[1], + column = change_cursor[2] + 1, + }) + then ui.notify("Source location is not visible", vim.log.levels.WARN) return false end @@ -226,8 +230,7 @@ function M.control_lines(card, keys) keys = keys or require("loopbiotic.config").values.keymaps local lines = {} if state.goal and state.goal.statement then - local goal = state.details_expanded and M.one_line(state.goal.statement) - or M.truncate(state.goal.statement, 52) + local goal = state.details_expanded and M.one_line(state.goal.statement) or M.truncate(state.goal.statement, 52) table.insert(lines, "Goal " .. goal) local completed = #(state.goal.completed_steps or {}) if completed > 0 then @@ -263,8 +266,7 @@ end function M.details_available(card) local goal = state.goal and state.goal.statement local explanation = card.explanation or card.title or "Local change" - return goal and vim.fn.strdisplaywidth(M.one_line(goal)) > 52 - or vim.fn.strdisplaywidth(M.one_line(explanation)) > 58 + return goal and vim.fn.strdisplaywidth(M.one_line(goal)) > 52 or vim.fn.strdisplaywidth(M.one_line(explanation)) > 58 end function M.toggle_details(card) @@ -292,10 +294,7 @@ end function M.observation_network(observations) local nodes = {} for index, observation in ipairs(observations) do - local kind = observation.kind == "hypothesis" and "H" or observation.kind == "signal" and "S" or "F" - local active = observation.active and "*" or "." - local repeats = (observation.occurrences or 1) > 1 and "x" .. observation.occurrences or "" - table.insert(nodes, string.format("[%s%d%s%s]", kind, index, active, repeats)) + table.insert(nodes, util.observation_node(observation, index)) end return table.concat(nodes, "--") @@ -429,13 +428,8 @@ function M.send(accepted, patch_ids, changed_files, error) return end - state.token_usage = message.result.token_usage - state.turn_token_usage = message.result.turn_token_usage - state.context_report = message.result.context_report - log.event("context_optimization", message.result.context_report or {}) - log.event("agent_attempts", message.result.attempts or {}) - state.goal = message.result.goal or state.goal - require("loopbiotic.card").show(message.result.card) + -- Patch results historically never updated state.backend_model. + session.apply_turn_result(message.result, { update_model = false }) end) end diff --git a/lua/loopbiotic/init.lua b/lua/loopbiotic/init.lua index a33b6e5..1c05767 100644 --- a/lua/loopbiotic/init.lua +++ b/lua/loopbiotic/init.lua @@ -5,10 +5,12 @@ local log = require("loopbiotic.log") local navigation = require("loopbiotic.navigation") local prompt = require("loopbiotic.prompt") local rpc = require("loopbiotic.rpc") +local session = require("loopbiotic.session") local state = require("loopbiotic.state") local status = require("loopbiotic.status") local thinking = require("loopbiotic.thinking") local ui = require("loopbiotic.ui") +local util = require("loopbiotic.util") local M = {} @@ -46,15 +48,7 @@ rpc.on_request("editor/open_location", function(params, respond) end) function M.workspace_location(file) - if type(file) ~= "string" or file == "" then - return false - end - - local root = vim.uv.fs_realpath(vim.fn.getcwd()) or vim.fs.normalize(vim.fn.getcwd()) - local target = vim.fn.fnamemodify(file, ":p") - target = vim.uv.fs_realpath(target) or vim.fs.normalize(target) - - return target == root or vim.startswith(target, root .. "/") + return util.in_workspace(file) end function M.setup(opts) @@ -134,14 +128,7 @@ function M.start(text, mode, source) end state.session_id = message.result.session_id - state.goal = message.result.goal or state.goal - state.token_usage = message.result.token_usage - state.turn_token_usage = message.result.turn_token_usage - state.backend_model = message.result.model or state.backend_model - state.context_report = message.result.context_report - log.event("context_optimization", message.result.context_report or {}) - log.event("agent_attempts", message.result.attempts or {}) - card.show(message.result.card) + session.apply_turn_result(message.result) end) end @@ -227,14 +214,7 @@ function M.action(action, opts) return end - state.token_usage = message.result.token_usage - state.turn_token_usage = message.result.turn_token_usage - state.backend_model = message.result.model or state.backend_model - state.context_report = message.result.context_report - log.event("context_optimization", message.result.context_report or {}) - log.event("agent_attempts", message.result.attempts or {}) - state.goal = message.result.goal or state.goal - card.show(message.result.card) + session.apply_turn_result(message.result) end) end @@ -268,20 +248,25 @@ function M.run_check() if #report.errors > 0 then local first = report.errors[1] - ui.notify(string.format( - "Loopbiotic check found %s error%s. First: %s:%s %s", - #report.errors, - #report.errors == 1 and "" or "s", - first.file, - first.line, - first.message - ), vim.log.levels.ERROR) + ui.notify( + string.format( + "Loopbiotic check found %s error%s. First: %s:%s %s", + #report.errors, + #report.errors == 1 and "" or "s", + first.file, + first.line, + first.message + ), + vim.log.levels.ERROR + ) elseif report.checked_files > 0 then - ui.notify(string.format( - "Loopbiotic check passed: no editor errors in %s changed buffer%s", - report.checked_files, - report.checked_files == 1 and "" or "s" - )) + ui.notify( + string.format( + "Loopbiotic check passed: no editor errors in %s changed buffer%s", + report.checked_files, + report.checked_files == 1 and "" or "s" + ) + ) else ui.notify("Loopbiotic check unavailable: no changed buffers are loaded", vim.log.levels.WARN) end @@ -347,14 +332,7 @@ function M.reply(text) return end - state.token_usage = message.result.token_usage - state.turn_token_usage = message.result.turn_token_usage - state.backend_model = message.result.model or state.backend_model - state.context_report = message.result.context_report - log.event("context_optimization", message.result.context_report or {}) - log.event("agent_attempts", message.result.attempts or {}) - state.goal = message.result.goal or state.goal - card.show(message.result.card) + session.apply_turn_result(message.result) end) end @@ -375,11 +353,8 @@ function M.confirm_agent_turn(action) return true end - local question = string.format( - "Loopbiotic session used %s tokens (budget %s).\nStart another agent turn?", - used, - budget - ) + local question = + string.format("Loopbiotic session used %s tokens (budget %s).\nStart another agent turn?", used, budget) return vim.fn.confirm(question, "&Continue\n&Cancel", 2, "Warning") == 1 end @@ -506,40 +481,7 @@ function M.reset() ui.close(state.thinking_win) status.hide() rpc.stop() - - state.session_id = nil - state.source_buf = nil - state.source_cursor = nil - state.card = nil - state.goal = nil - state.last_card = nil - state.prompt_win = nil - state.prompt_buf = nil - state.prompt_frame_win = nil - state.prompt_frame_buf = nil - state.card_win = nil - state.card_buf = nil - state.status_win = nil - state.status_buf = nil - state.diff_tab = nil - state.diff_buf = nil - state.diff_win = nil - state.diff_source_buf = nil - state.diff_source_tick = nil - state.diff_first_row = nil - state.diff_cursor = nil - state.token_usage = nil - state.turn_token_usage = nil - state.backend_model = nil - state.context_report = nil - state.workspace_hints = nil - state.completion_notified_card = nil - state.completion_checked_card = nil - state.details_card = nil - state.details_expanded = false - state.navigated_card = nil - state.thinking_request_id = nil - state.thinking_session_id = nil + state.reset() ui.notify("Loopbiotic reset") end diff --git a/lua/loopbiotic/navigation.lua b/lua/loopbiotic/navigation.lua index 41b532f..81ae508 100644 --- a/lua/loopbiotic/navigation.lua +++ b/lua/loopbiotic/navigation.lua @@ -1,6 +1,7 @@ local config = require("loopbiotic.config") local extmarks = require("loopbiotic.extmarks") local state = require("loopbiotic.state") +local util = require("loopbiotic.util") local M = {} @@ -101,15 +102,7 @@ function M.from_card(card) end function M.card_location(card) - if type(card.next_move) == "table" and card.next_move.kind == "open_location" then - return card.next_move - elseif type(card.evidence) == "table" then - return card.evidence - elseif type(card.location) == "table" then - return card.location - end - - return nil + return util.card_location(card) end return M diff --git a/lua/loopbiotic/session.lua b/lua/loopbiotic/session.lua new file mode 100644 index 0000000..0625cd7 --- /dev/null +++ b/lua/loopbiotic/session.lua @@ -0,0 +1,28 @@ +local log = require("loopbiotic.log") +local state = require("loopbiotic.state") + +local M = {} + +-- Apply the shared tail of a successful turn result (session/start, +-- session/action, session/reply, patch/apply_result): record usage and +-- reports, log them, adopt the updated goal, and show the resulting card. +-- Call-site-specific handling (thinking guards, stale-session checks, +-- session_id adoption) stays at the call sites. +---@param result table backend turn result +---@param opts? { update_model?: boolean } update_model=false keeps state.backend_model untouched +function M.apply_turn_result(result, opts) + opts = opts or {} + + state.token_usage = result.token_usage + state.turn_token_usage = result.turn_token_usage + if opts.update_model ~= false then + state.backend_model = result.model or state.backend_model + end + state.context_report = result.context_report + log.event("context_optimization", result.context_report or {}) + log.event("agent_attempts", result.attempts or {}) + state.goal = result.goal or state.goal + require("loopbiotic.card").show(result.card) +end + +return M diff --git a/lua/loopbiotic/state.lua b/lua/loopbiotic/state.lua index 4c90caa..b9fbe56 100644 --- a/lua/loopbiotic/state.lua +++ b/lua/loopbiotic/state.lua @@ -1,42 +1,120 @@ -local M = { - session_id = nil, - source_buf = nil, - source_cursor = nil, - card = nil, - goal = nil, - prompt_win = nil, - prompt_buf = nil, - prompt_frame_win = nil, - prompt_frame_buf = nil, - card_win = nil, - card_buf = nil, - status_win = nil, - status_buf = nil, - diff_tab = nil, - diff_buf = nil, - diff_win = nil, - diff_source_buf = nil, - diff_source_tick = nil, - diff_first_row = nil, - diff_cursor = nil, - thinking_win = nil, - thinking_buf = nil, - thinking_timer = nil, - thinking_request_id = nil, - thinking_session_id = nil, - thinking_started_at = nil, - thinking_label = nil, - thinking_steps = nil, - last_card = nil, - token_usage = nil, - turn_token_usage = nil, - context_report = nil, - workspace_hints = nil, - completion_notified_card = nil, - completion_checked_card = nil, - details_card = nil, +---@class LoopbioticGoal +---@field statement string +---@field completed_steps string[] +---@field known_observations table[] +---@field status string "active" | "needs_review" | "complete" +---@field next_step? string + +---@class LoopbioticTokenUsage +---@field input_tokens? integer +---@field cached_input_tokens? integer +---@field output_tokens? integer +---@field total_tokens? integer +---@field estimated? boolean + +---@class LoopbioticState +---@field session_id string|nil active backend session +---@field source_buf integer|nil buffer the session was started from +---@field source_cursor integer[]|nil { line, column } (0-based column) +---@field card LoopbioticCard|nil card currently shown +---@field goal LoopbioticGoal|nil +---@field prompt_win integer|nil +---@field prompt_buf integer|nil +---@field prompt_frame_win integer|nil +---@field prompt_frame_buf integer|nil +---@field card_win integer|nil +---@field card_buf integer|nil +---@field status_win integer|nil +---@field status_buf integer|nil +---@field diff_tab integer|nil +---@field diff_buf integer|nil draft buffer of the inline patch preview +---@field diff_win integer|nil +---@field diff_source_buf integer|nil +---@field diff_source_tick integer|nil changedtick guard for the draft +---@field diff_first_row integer|nil +---@field diff_cursor integer[]|nil +---@field thinking_win integer|nil +---@field thinking_buf integer|nil +---@field thinking_timer userdata|nil +---@field thinking_frame integer|nil +---@field thinking_request_id string|nil +---@field thinking_session_id string|nil +---@field thinking_started_at integer|nil +---@field thinking_label string|nil +---@field thinking_steps table[]|nil +---@field last_card LoopbioticCard|nil +---@field token_usage LoopbioticTokenUsage|nil +---@field turn_token_usage LoopbioticTokenUsage|nil +---@field backend_model string|nil model the backend reported using +---@field context_report table|nil +---@field workspace_hints table[]|nil +---@field completion_notified_card string|nil +---@field completion_checked_card string|nil +---@field details_card LoopbioticCard|nil +---@field details_expanded boolean +---@field navigated_card LoopbioticCard|nil +---@field reset fun() + +-- Every mutable field with its initial value. Fields that start as nil list +-- vim.NIL here so reset() can restore them without losing the key. +local defaults = { + session_id = vim.NIL, + source_buf = vim.NIL, + source_cursor = vim.NIL, + card = vim.NIL, + goal = vim.NIL, + prompt_win = vim.NIL, + prompt_buf = vim.NIL, + prompt_frame_win = vim.NIL, + prompt_frame_buf = vim.NIL, + card_win = vim.NIL, + card_buf = vim.NIL, + status_win = vim.NIL, + status_buf = vim.NIL, + diff_tab = vim.NIL, + diff_buf = vim.NIL, + diff_win = vim.NIL, + diff_source_buf = vim.NIL, + diff_source_tick = vim.NIL, + diff_first_row = vim.NIL, + diff_cursor = vim.NIL, + thinking_win = vim.NIL, + thinking_buf = vim.NIL, + thinking_timer = vim.NIL, + thinking_frame = vim.NIL, + thinking_request_id = vim.NIL, + thinking_session_id = vim.NIL, + thinking_started_at = vim.NIL, + thinking_label = vim.NIL, + thinking_steps = vim.NIL, + last_card = vim.NIL, + token_usage = vim.NIL, + turn_token_usage = vim.NIL, + backend_model = vim.NIL, + context_report = vim.NIL, + workspace_hints = vim.NIL, + completion_notified_card = vim.NIL, + completion_checked_card = vim.NIL, + details_card = vim.NIL, details_expanded = false, - navigated_card = nil, + navigated_card = vim.NIL, } +---@type LoopbioticState +local M = {} + +-- Restore every field to its initial value. Non-state cleanup (timers, +-- windows, the RPC job) is the caller's job; see require("loopbiotic").reset(). +function M.reset() + for field, value in pairs(defaults) do + if value == vim.NIL then + M[field] = nil + else + M[field] = vim.deepcopy(value) + end + end +end + +M.reset() + return M diff --git a/lua/loopbiotic/util.lua b/lua/loopbiotic/util.lua new file mode 100644 index 0000000..7a904b0 --- /dev/null +++ b/lua/loopbiotic/util.lua @@ -0,0 +1,52 @@ +-- Small shared helpers with no dependencies on other loopbiotic modules. +local M = {} + +-- The location a card points at, in priority order: an explicit next move, +-- then evidence, then a plain location. +---@param card LoopbioticCard|table +---@return table|nil location { file, line, column, ... } or nil +function M.card_location(card) + if type(card.next_move) == "table" and card.next_move.kind == "open_location" then + return card.next_move + end + if type(card.evidence) == "table" then + return card.evidence + end + if type(card.location) == "table" then + return card.location + end + + return nil +end + +-- Compact node label for one goal observation, e.g. "[H1*x2]". +---@param observation { kind?: string, active?: boolean, occurrences?: integer } +---@param index integer +---@return string +function M.observation_node(observation, index) + local kind = observation.kind == "hypothesis" and "H" or observation.kind == "signal" and "S" or "F" + local active = observation.active and "*" or "." + local repeats = (observation.occurrences or 1) > 1 and "x" .. observation.occurrences or "" + + return string.format("[%s%d%s%s]", kind, index, active, repeats) +end + +-- Whether file lies inside root (default: the current working directory). +-- Symlinks are resolved when the paths exist. +---@param file string|nil +---@param root string|nil +---@return boolean +function M.in_workspace(file, root) + if type(file) ~= "string" or file == "" then + return false + end + + root = root or vim.fn.getcwd() + root = vim.uv.fs_realpath(root) or vim.fs.normalize(vim.fn.fnamemodify(root, ":p"):gsub("/$", "")) + local target = vim.fn.fnamemodify(file, ":p") + target = vim.uv.fs_realpath(target) or vim.fs.normalize(target) + + return target == root or vim.startswith(target, root .. "/") +end + +return M From 8b16f4efc1a00b5c2e31bb448ee852457cc5c0b2 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:36:46 +0200 Subject: [PATCH 10/37] chore: add stylua, selene, and LuaLS configs; format Lua --- .luarc.json | 13 +++++++++++++ .stylua.toml | 6 ++++++ lua/loopbiotic/apply.lua | 32 ++++++++++++++++++++++++++++++ lua/loopbiotic/config.lua | 39 ++++++++++++++++++++++++++++++++++--- lua/loopbiotic/log.lua | 7 +------ lua/loopbiotic/thinking.lua | 10 +++++++--- lua/loopbiotic/ui.lua | 3 ++- selene.toml | 5 +++++ vim.yml | 7 +++++++ 9 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 .luarc.json create mode 100644 .stylua.toml create mode 100644 selene.toml create mode 100644 vim.yml diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..9c3c85e --- /dev/null +++ b/.luarc.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "runtime": { + "version": "LuaJIT" + }, + "workspace": { + "library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], + "checkThirdParty": false + }, + "diagnostics": { + "globals": ["vim"] + } +} diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..6090f42 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,6 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" diff --git a/lua/loopbiotic/apply.lua b/lua/loopbiotic/apply.lua index 05925c3..1355e03 100644 --- a/lua/loopbiotic/apply.lua +++ b/lua/loopbiotic/apply.lua @@ -1,5 +1,11 @@ local M = {} +---@class LoopbioticHunk +---@field old_start integer 1-based first source line of the hunk +---@field old_len integer source line count claimed by the header +---@field lines { kind: "context"|"remove"|"add", text: string }[] +---@field old_lines string[] context and removed lines, in order + -- Two lines match if they are equal ignoring leading/trailing whitespace. -- Mirrors the daemon's tolerant patch matching so a hunk whose context drifted -- on indentation still applies against the live buffer. @@ -7,6 +13,10 @@ local function line_matches(a, b) return vim.trim(a or "") == vim.trim(b or "") end +-- Apply a backend file patch to its (possibly newly opened) buffer. +---@param file_patch { file: string, diff: string } +---@return boolean ok +---@return string|nil error function M.patch(file_patch) local buf = M.buffer(file_patch.file) @@ -28,6 +38,9 @@ function M.patch(file_patch) return true, nil end +-- Find the loaded buffer editing a file, if any. +---@param file string +---@return integer|nil buf function M.buffer(file) local target = vim.fn.fnamemodify(file, ":p") @@ -40,6 +53,11 @@ function M.buffer(file) return nil end +-- Apply a single-hunk unified diff to source lines. Errors when the hunk +-- cannot be parsed or its context no longer matches. +---@param source string[] +---@param diff string +---@return string[] output function M.apply_diff(source, diff) local hunk = M.parse_hunk(diff) if hunk.old_len == 0 and #source == 1 and source[1] == "" then @@ -69,6 +87,9 @@ function M.apply_diff(source, diff) return output end +-- Parse a unified diff that must contain exactly one hunk. Errors otherwise. +---@param diff string +---@return LoopbioticHunk function M.parse_hunk(diff) local hunk = { old_start = nil, old_len = nil, lines = {}, old_lines = {} } @@ -108,6 +129,12 @@ function M.parse_hunk(diff) return hunk end +-- Locate where the hunk applies: the header position when it still matches, +-- otherwise the unique whitespace-tolerant match. Errors when the context is +-- missing or ambiguous. +---@param source string[] +---@param hunk LoopbioticHunk +---@return integer offset 0-based line offset the hunk applies at function M.resolve_start(source, hunk) if hunk.old_len == 0 then if #source == 0 or (#source == 1 and source[1] == "") then @@ -138,6 +165,11 @@ function M.resolve_start(source, hunk) return matches[1] end +-- Whether expected lines match source at a 0-based offset (whitespace-tolerant). +---@param source string[] +---@param start integer +---@param expected string[] +---@return boolean function M.matches_at(source, start, expected) for index, line in ipairs(expected) do if not line_matches(source[start + index], line) then diff --git a/lua/loopbiotic/config.lua b/lua/loopbiotic/config.lua index 8a9d773..e453fcd 100644 --- a/lua/loopbiotic/config.lua +++ b/lua/loopbiotic/config.lua @@ -1,5 +1,40 @@ local M = {} +---@class LoopbioticBackendConfig +---@field command string|nil explicit loopbioticd path; nil resolves/installs one +---@field args string[] +---@field mode string default prompt mode ("auto", "fix", "explain", ...) +---@field agent string key into LoopbioticConfig.agents +---@field prefetch "off"|"fix" speculative patch prefetch +---@field token_budget integer ask before another turn past this session total; 0 disables + +---@class LoopbioticAgentConfig +---@field kind "mock"|"agent"|"codex_app"|"claude_app"|"ollama"|"generic" +---@field command? string +---@field args? string[] +---@field model? string +---@field model_flag? string +---@field models? string[] +---@field effort? string codex_app only +---@field discovery_model? string claude_app only +---@field discovery_thinking? integer claude_app only +---@field host? string ollama only +---@field keep_alive? string ollama only + +---@class LoopbioticConfig +---@field backend LoopbioticBackendConfig +---@field distribution { repository?: string, base_url?: string, auto_install?: boolean, version?: string } +---@field logging { enabled: boolean, include_content: boolean, max_files: integer } +---@field agents table +---@field keymaps table +---@field prompt { border: string, width: integer, height: integer, padding_x: integer, padding_y: integer, zindex: integer } +---@field card { border: string, max_width: integer, max_height: integer } +---@field thinking { enabled: boolean, interval: integer } +---@field context table context capture limits, optimization policy, and LSP hint options +---@field navigation { open: "current"|"tab"|"split"|"vsplit", annotate: boolean } +---@field diff { layout: string, apply_to_buffer: boolean, max_changed_lines: integer } + +---@type LoopbioticConfig M.values = { backend = { command = nil, @@ -348,9 +383,7 @@ function M.agent_env(agent) LOOPBIOTIC_CLAUDE_ARGS_JSON = vim.json.encode(args), LOOPBIOTIC_CLAUDE_MODEL = agent.model or "", LOOPBIOTIC_CLAUDE_DISCOVERY_MODEL = agent.discovery_model or "", - LOOPBIOTIC_CLAUDE_DISCOVERY_THINKING = agent.discovery_thinking - and tostring(agent.discovery_thinking) - or "", + LOOPBIOTIC_CLAUDE_DISCOVERY_THINKING = agent.discovery_thinking and tostring(agent.discovery_thinking) or "", } end diff --git a/lua/loopbiotic/log.lua b/lua/loopbiotic/log.lua index f57732b..8f8641b 100644 --- a/lua/loopbiotic/log.lua +++ b/lua/loopbiotic/log.lua @@ -28,12 +28,7 @@ function M.path() local ok = pcall(vim.fn.mkdir, directory, "p") if ok and vim.fn.isdirectory(directory) == 1 then M.prune(directory) - session_path = string.format( - "%s/%s-%s.jsonl", - directory, - os.date("%Y%m%dT%H%M%S"), - vim.fn.getpid() - ) + session_path = string.format("%s/%s-%s.jsonl", directory, os.date("%Y%m%dT%H%M%S"), vim.fn.getpid()) else session_path = vim.fn.tempname() .. "-loopbiotic.jsonl" end diff --git a/lua/loopbiotic/thinking.lua b/lua/loopbiotic/thinking.lua index 55ad4b4..9c04d17 100644 --- a/lua/loopbiotic/thinking.lua +++ b/lua/loopbiotic/thinking.lua @@ -38,9 +38,13 @@ function M.start(label, session_id) end, { buffer = state.thinking_buf, nowait = true, silent = true }) state.thinking_timer = uv.new_timer() - state.thinking_timer:start(0, config.values.thinking.interval, vim.schedule_wrap(function() - M.tick(request_id) - end)) + state.thinking_timer:start( + 0, + config.values.thinking.interval, + vim.schedule_wrap(function() + M.tick(request_id) + end) + ) return request_id end diff --git a/lua/loopbiotic/ui.lua b/lua/loopbiotic/ui.lua index 4448b56..18066f8 100644 --- a/lua/loopbiotic/ui.lua +++ b/lua/loopbiotic/ui.lua @@ -61,7 +61,8 @@ function M.render(buf, win, lines, opts) opts = opts or {} lines = M.lines(lines) - if win + if + win and vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_tabpage(win) ~= vim.api.nvim_get_current_tabpage() then diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..090d633 --- /dev/null +++ b/selene.toml @@ -0,0 +1,5 @@ +std = "vim" + +[rules] +# Card and config tables legitimately mix array and keyed parts. +mixed_table = "allow" diff --git a/vim.yml b/vim.yml new file mode 100644 index 0000000..246e61c --- /dev/null +++ b/vim.yml @@ -0,0 +1,7 @@ +# Minimal Neovim standard library definition for selene. +# The plugin targets Neovim 0.10+ (LuaJIT / Lua 5.1). +--- +base: lua51 +globals: + vim: + any: true From 589a85ff9fd71fc61ee8fbee4c83ad21785568a0 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:36:46 +0200 Subject: [PATCH 11/37] test: add headless Lua unit tests and real daemon smoke round-trip --- scripts/headless-smoke.lua | 50 +++++++++++++++++++++++- tests/lua/run.lua | 64 ++++++++++++++++++++++++++++++ tests/lua/test_apply.lua | 79 ++++++++++++++++++++++++++++++++++++++ tests/lua/test_session.lua | 73 +++++++++++++++++++++++++++++++++++ tests/lua/test_state.lua | 32 +++++++++++++++ 5 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 tests/lua/run.lua create mode 100644 tests/lua/test_apply.lua create mode 100644 tests/lua/test_session.lua create mode 100644 tests/lua/test_state.lua diff --git a/scripts/headless-smoke.lua b/scripts/headless-smoke.lua index 738c82d..32ddd93 100644 --- a/scripts/headless-smoke.lua +++ b/scripts/headless-smoke.lua @@ -172,7 +172,8 @@ state.session_id = nil state.card = nil vim.api.nvim_buf_delete(focus_source, { force = true }) local long_goal = "Mam tutaj problem, bo ta pętla nie uwzględnia wszystkich elementów z kolejnych przebiegów" -local long_explanation = "Oznacza korzeń podglądu tym samym dyskryminatorem w węźle nadrzędnym i zachowuje pełny opis zmiany" +local long_explanation = + "Oznacza korzeń podglądu tym samym dyskryminatorem w węźle nadrzędnym i zachowuje pełny opis zmiany" local patch_card = { kind = "patch", title = "Preview root", @@ -250,3 +251,50 @@ state.navigated_card = nil vim.cmd("tabonly") print("Loopbiotic headless smoke test passed") + +-- Optional real round-trip: when LOOPBIOTIC_SMOKE_BIN points at a loopbioticd +-- binary, drive one session over JSON-RPC against the mock agent. Without the +-- variable the smoke test keeps the stubbed behavior above and stops here. +local smoke_bin = vim.env.LOOPBIOTIC_SMOKE_BIN +if smoke_bin and smoke_bin ~= "" then + local loopbiotic = require("loopbiotic") + local rpc = require("loopbiotic.rpc") + + assert(vim.fn.executable(smoke_bin) == 1, "LOOPBIOTIC_SMOKE_BIN is not executable: " .. smoke_bin) + loopbiotic.reset() + config.values.backend.command = vim.fn.fnamemodify(smoke_bin, ":p") + config.values.backend.args = { "--stdio" } + config.values.backend.agent = "mock" + + vim.cmd("edit README.md") + -- The /hypothesis prefix pins the first card kind, keeping the round-trip + -- deterministic against the mock agent. + loopbiotic.start("/hypothesis Smoke round-trip: inspect this file", "auto") + local started = vim.wait(15000, function() + return state.session_id ~= nil and state.card ~= nil + end, 50) + assert(started, "smoke round-trip timed out waiting for the first card") + assert(state.card.kind == "hypothesis", "unexpected first card kind: " .. tostring(state.card.kind)) + assert(loopbiotic.action_available(state.card, "follow"), "first card offers no follow action") + assert(loopbiotic.actions_visible(), "card actions are not visible after the first turn") + + local first_card_id = state.card.id + loopbiotic.action("follow") + local followed = vim.wait(15000, function() + return state.card ~= nil and state.card.id ~= first_card_id and not state.thinking_request_id + end, 50) + assert(followed, "smoke round-trip timed out waiting for the follow card") + assert(state.card.kind == "finding", "unexpected follow card kind: " .. tostring(state.card.kind)) + + -- Stopping the backend mid-request must fail the in-flight callback and + -- clear the thinking spinner instead of leaving it stuck. + assert(loopbiotic.action_available(state.card, "why"), "follow card offers no why action") + loopbiotic.action("why") + assert(state.thinking_request_id, "expected a thinking spinner during the agent turn") + rpc.stop() + assert(not state.thinking_request_id, "rpc.stop left the thinking spinner active") + + rpc.stop() + loopbiotic.reset() + print("Loopbiotic smoke round-trip passed") +end diff --git a/tests/lua/run.lua b/tests/lua/run.lua new file mode 100644 index 0000000..df56d00 --- /dev/null +++ b/tests/lua/run.lua @@ -0,0 +1,64 @@ +-- Minimal dependency-free test runner: +-- nvim --headless -u NONE -i NONE -l tests/lua/run.lua +-- Prints one line per test and exits nonzero when any test fails. + +local script = debug.getinfo(1, "S").source:sub(2) +local root = vim.fn.fnamemodify(script, ":p:h:h:h") +vim.opt.runtimepath:append(root) + +-- Keep test runs from writing session logs into stdpath("state"). +require("loopbiotic.config").values.logging.enabled = false + +local passed = 0 +local failed = 0 + +local t = {} + +---@param name string +---@param fn fun() +function t.test(name, fn) + local ok, err = pcall(fn) + if ok then + passed = passed + 1 + print("ok " .. name) + else + failed = failed + 1 + print("FAIL " .. name) + print(" " .. tostring(err)) + end +end + +function t.eq(actual, expected, label) + if not vim.deep_equal(actual, expected) then + error( + string.format( + "%sexpected %s, got %s", + label and (label .. ": ") or "", + vim.inspect(expected), + vim.inspect(actual) + ), + 2 + ) + end +end + +-- Assert that fn raises an error whose message contains fragment. +function t.fails(fragment, fn) + local ok, err = pcall(fn) + if ok then + error(string.format("expected an error containing %q, but no error was raised", fragment), 2) + end + if not tostring(err):find(fragment, 1, true) then + error(string.format("expected error containing %q, got %q", fragment, tostring(err)), 2) + end +end + +local files = vim.fn.globpath(root .. "/tests/lua", "test_*.lua", false, true) +table.sort(files) +for _, file in ipairs(files) do + local case = dofile(file) + case(t) +end + +print(string.format("%d passed, %d failed", passed, failed)) +os.exit(failed == 0 and 0 or 1) diff --git a/tests/lua/test_apply.lua b/tests/lua/test_apply.lua new file mode 100644 index 0000000..6b99c7c --- /dev/null +++ b/tests/lua/test_apply.lua @@ -0,0 +1,79 @@ +return function(t) + local apply = require("loopbiotic.apply") + + t.test("parse_hunk parses a normal hunk", function() + local hunk = apply.parse_hunk("@@ -3,2 +3,2 @@\n context\n-old\n+new\n") + t.eq(hunk.old_start, 3, "old_start") + t.eq(hunk.old_len, 2, "old_len") + t.eq(#hunk.lines, 3, "line count") + t.eq(hunk.lines[3], { kind = "add", text = "new" }) + t.eq(hunk.old_lines, { "context", "old" }) + end) + + t.test("parse_hunk rejects multiple hunks", function() + t.fails("exactly one hunk", function() + apply.parse_hunk("@@ -1,1 +1,1 @@\n-a\n+b\n@@ -5,1 +5,1 @@\n-c\n+d\n") + end) + end) + + t.test("parse_hunk defaults old_len to 1 for @@ -N @@ headers", function() + local hunk = apply.parse_hunk("@@ -7 @@\n-only\n+line\n") + t.eq(hunk.old_start, 7, "old_start") + t.eq(hunk.old_len, 1, "old_len") + end) + + t.test("parse_hunk rejects a hunk without source context", function() + t.fails("no source context", function() + apply.parse_hunk("@@ -1,2 +1,3 @@\n+added only\n") + end) + end) + + t.test("parse_hunk rejects invalid patch lines", function() + t.fails("Invalid patch line", function() + apply.parse_hunk("@@ -1,1 +1,1 @@\n?what\n") + end) + end) + + t.test("apply_diff applies at an exactly matching header position", function() + local out = apply.apply_diff({ "a", "b", "c" }, "@@ -2,1 +2,1 @@\n-b\n+B\n") + t.eq(out, { "a", "B", "c" }) + end) + + t.test("apply_diff tolerates whitespace drift and keeps buffer lines", function() + local out = apply.apply_diff({ "\tguard", "\told" }, "@@ -1,2 +1,2 @@\n guard \n- old\n+ new\n") + t.eq(out, { "\tguard", " new" }) + end) + + t.test("resolve_start relocates a hunk whose header start is stale", function() + local out = apply.apply_diff({ "x", "target", "y" }, "@@ -9,1 +9,1 @@\n-target\n+replaced\n") + t.eq(out, { "x", "replaced", "y" }) + end) + + t.test("resolve_start fails on ambiguous context", function() + local hunk = apply.parse_hunk("@@ -9,1 +9,1 @@\n-same\n+other\n") + t.fails("ambiguous", function() + apply.resolve_start({ "same", "same" }, hunk) + end) + end) + + t.test("resolve_start fails on missing context", function() + local hunk = apply.parse_hunk("@@ -1,1 +1,1 @@\n-absent\n+other\n") + t.fails("not found", function() + apply.resolve_start({ "something", "else" }, hunk) + end) + end) + + t.test("apply_diff creates content in an empty file", function() + local out = apply.apply_diff({ "" }, "@@ -1,0 +1,2 @@\n+first\n+second\n") + t.eq(out, { "first", "second" }) + end) + + t.test("resolve_start only creates files from empty sources", function() + local hunk = apply.parse_hunk("@@ -1,0 +1,1 @@\n+first\n") + t.eq(apply.resolve_start({}, hunk), 0, "empty source") + t.eq(apply.resolve_start({ "" }, hunk), 0, "single blank line") + t.fails("empty file", function() + apply.resolve_start({ "occupied" }, hunk) + end) + end) +end diff --git a/tests/lua/test_session.lua b/tests/lua/test_session.lua new file mode 100644 index 0000000..2cfc275 --- /dev/null +++ b/tests/lua/test_session.lua @@ -0,0 +1,73 @@ +return function(t) + local card = require("loopbiotic.card") + local session = require("loopbiotic.session") + local state = require("loopbiotic.state") + + -- Stub card.show so the helper stays a pure state transition in tests. + local function with_stubbed_show(fn) + local original = card.show + local shown = {} + card.show = function(shown_card) + table.insert(shown, shown_card) + end + local ok, err = pcall(fn, shown) + card.show = original + if not ok then + error(err, 0) + end + end + + local function turn_result() + return { + session_id = "session-1", + token_usage = { total_tokens = 20, input_tokens = 15, output_tokens = 5 }, + turn_token_usage = { total_tokens = 8 }, + model = "reported-model", + context_report = { enabled = true, used_tokens = 100 }, + attempts = { { outcome = "accepted" } }, + goal = { statement = "updated goal" }, + card = { id = "c1", kind = "finding", title = "Found it" }, + } + end + + t.test("apply_turn_result records usage, goal, model, and shows the card", function() + state.reset() + with_stubbed_show(function(shown) + session.apply_turn_result(turn_result()) + t.eq(state.token_usage.total_tokens, 20, "token_usage") + t.eq(state.turn_token_usage.total_tokens, 8, "turn_token_usage") + t.eq(state.backend_model, "reported-model", "backend_model") + t.eq(state.context_report.enabled, true, "context_report") + t.eq(state.goal.statement, "updated goal", "goal") + t.eq(#shown, 1, "card.show calls") + t.eq(shown[1].id, "c1", "shown card") + end) + state.reset() + end) + + t.test("apply_turn_result keeps the previous goal and model when absent", function() + state.reset() + state.goal = { statement = "existing goal" } + state.backend_model = "existing-model" + local result = turn_result() + result.goal = nil + result.model = nil + with_stubbed_show(function() + session.apply_turn_result(result) + t.eq(state.goal.statement, "existing goal", "goal") + t.eq(state.backend_model, "existing-model", "backend_model") + end) + state.reset() + end) + + t.test("apply_turn_result can skip the model update (patch results)", function() + state.reset() + state.backend_model = "existing-model" + with_stubbed_show(function() + session.apply_turn_result(turn_result(), { update_model = false }) + t.eq(state.backend_model, "existing-model", "backend_model") + t.eq(state.token_usage.total_tokens, 20, "token_usage") + end) + state.reset() + end) +end diff --git a/tests/lua/test_state.lua b/tests/lua/test_state.lua new file mode 100644 index 0000000..d31dda3 --- /dev/null +++ b/tests/lua/test_state.lua @@ -0,0 +1,32 @@ +return function(t) + local state = require("loopbiotic.state") + + t.test("state.reset restores defaults after mutation", function() + state.session_id = "session-1" + state.source_buf = 42 + state.card = { id = "c1", kind = "finding" } + state.goal = { statement = "goal" } + state.token_usage = { total_tokens = 12 } + state.details_expanded = true + state.thinking_frame = 7 + state.workspace_hints = { { file = "a.lua" } } + + state.reset() + + t.eq(state.session_id, nil, "session_id") + t.eq(state.source_buf, nil, "source_buf") + t.eq(state.card, nil, "card") + t.eq(state.goal, nil, "goal") + t.eq(state.token_usage, nil, "token_usage") + t.eq(state.details_expanded, false, "details_expanded") + t.eq(state.thinking_frame, nil, "thinking_frame") + t.eq(state.workspace_hints, nil, "workspace_hints") + end) + + t.test("state.reset keeps reset callable and idempotent", function() + state.reset() + state.reset() + t.eq(type(state.reset), "function") + t.eq(state.details_expanded, false) + end) +end From e035c2f79d4f66dacb7a4cf04f25cdadd2ca6462 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:37:51 +0200 Subject: [PATCH 12/37] feat: reject mismatched client protocol versions at initialize --- rust/crates/loopbioticd/src/main.rs | 88 ++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 8 deletions(-) diff --git a/rust/crates/loopbioticd/src/main.rs b/rust/crates/loopbioticd/src/main.rs index 7501c50..398c37b 100644 --- a/rust/crates/loopbioticd/src/main.rs +++ b/rust/crates/loopbioticd/src/main.rs @@ -21,6 +21,9 @@ mod token_report; const OPEN_LOCATION_TIMEOUT: Duration = Duration::from_secs(120); const READ_FILE_TIMEOUT: Duration = Duration::from_secs(10); +/// JSON-RPC error code returned by `initialize` when the client announces a +/// protocol version that differs from [`loopbiotic_protocol::PROTOCOL_VERSION`]. +const PROTOCOL_MISMATCH_CODE: i64 = -32001; static NEXT_EDITOR_REQUEST_ID: AtomicU64 = AtomicU64::new(1); #[tokio::main] @@ -82,7 +85,12 @@ async fn serve_stdio() -> Result<()> { loop { let line = { - let queued = deferred.lock().expect("deferred lock").pop_front(); + // A poisoned lock only means another thread panicked while holding + // it; the queue holds whole lines, so the inner data is still valid. + let queued = deferred + .lock() + .unwrap_or_else(|error| error.into_inner()) + .pop_front(); match queued { Some(line) => line, None => match lines.lock().await.recv().await { @@ -229,7 +237,12 @@ async fn request_editor_context( .cloned() .and_then(|context| serde_json::from_value::(context).ok()); } - deferred.lock().expect("deferred lock").push_back(line); + // A poisoned lock only means another thread panicked while holding + // it; the queue holds whole lines, so the inner data is still valid. + deferred + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push_back(line); } } @@ -280,12 +293,34 @@ impl Server { ) -> Result { let id = request.id.clone(); let result = match request.method.as_str() { - "initialize" => json!({ - "server": "loopbioticd", - "version": env!("CARGO_PKG_VERSION"), - "protocol_version": loopbiotic_protocol::PROTOCOL_VERSION, - "backend": self.backend.capabilities(), - }), + "initialize" => { + // Old clients omit params.client.protocol_version entirely; + // the check only runs when the client announces a version. + let client_version = request + .params + .get("client") + .and_then(|client| client.get("protocol_version")) + .and_then(Value::as_u64); + if let Some(client_version) = client_version + && client_version != u64::from(loopbiotic_protocol::PROTOCOL_VERSION) + { + return Ok(JsonRpcResponse::err( + id, + PROTOCOL_MISMATCH_CODE, + format!( + "protocol version mismatch: client speaks protocol version {client_version}, loopbioticd speaks {}; update the Loopbiotic plugin and loopbioticd so both are on the same version", + loopbiotic_protocol::PROTOCOL_VERSION + ), + )); + } + + json!({ + "server": "loopbioticd", + "version": env!("CARGO_PKG_VERSION"), + "protocol_version": loopbiotic_protocol::PROTOCOL_VERSION, + "backend": self.backend.capabilities(), + }) + } "backend/list" => json!([self.backend.capabilities()]), "session/start" => { let params = parse::(&id, request.params)?; @@ -556,3 +591,40 @@ fn print_help() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::is_stale_server_response; + + #[test] + fn stale_detects_response_to_daemon_initiated_request() { + assert!(is_stale_server_response( + r#"{"jsonrpc":"2.0","id":"loopbioticd_7","result":{"granted":false}}"# + )); + } + + #[test] + fn stale_ignores_requests_even_with_daemon_style_id() { + assert!(!is_stale_server_response( + r#"{"jsonrpc":"2.0","id":"loopbioticd_7","method":"editor/open_location","params":{}}"# + )); + } + + #[test] + fn stale_ignores_client_responses_and_requests() { + assert!(!is_stale_server_response( + r#"{"jsonrpc":"2.0","id":"client_1","result":{}}"# + )); + assert!(!is_stale_server_response( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"# + )); + } + + #[test] + fn stale_ignores_non_json_and_numeric_ids() { + assert!(!is_stale_server_response("not json")); + assert!(!is_stale_server_response( + r#"{"jsonrpc":"2.0","id":42,"result":{}}"# + )); + } +} From af72f3421a727598f5eaa386839be8736854c268 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:37:51 +0200 Subject: [PATCH 13/37] test: drive loopbioticd JSON-RPC over stdio with the mock backend --- rust/crates/loopbioticd/tests/rpc.rs | 304 +++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 rust/crates/loopbioticd/tests/rpc.rs diff --git a/rust/crates/loopbioticd/tests/rpc.rs b/rust/crates/loopbioticd/tests/rpc.rs new file mode 100644 index 0000000..fd90dde --- /dev/null +++ b/rust/crates/loopbioticd/tests/rpc.rs @@ -0,0 +1,304 @@ +//! End-to-end tests that spawn the loopbioticd binary, select the mock +//! backend, and speak newline-delimited JSON-RPC over its stdio. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; +use std::time::Duration; + +use loopbiotic_protocol::PROTOCOL_VERSION; +use serde_json::{Value, json}; + +/// Generous per-message deadline so slow CI cannot flake, while a hung daemon +/// still fails the test instead of blocking forever. +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); + +struct Daemon { + child: Child, + stdin: ChildStdin, + lines: Receiver, +} + +impl Daemon { + fn spawn() -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_loopbioticd")) + .arg("--stdio") + .env("LOOPBIOTIC_BACKEND", "mock") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn loopbioticd"); + let stdin = child.stdin.take().expect("child stdin"); + let stdout = child.stdout.take().expect("child stdout"); + + // A reader thread feeds a channel so tests can apply deadlines to + // every read instead of blocking on the pipe. + let (line_tx, lines) = channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { + break; + }; + if line_tx.send(line).is_err() { + break; + } + } + }); + + Self { + child, + stdin, + lines, + } + } + + fn send(&mut self, value: &Value) { + let mut line = value.to_string(); + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .expect("write to daemon stdin"); + self.stdin.flush().expect("flush daemon stdin"); + } + + fn request(&mut self, id: &str, method: &str, params: Value) -> Value { + self.send(&json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + })); + self.response_for(id) + } + + /// Reads messages until the response with the given id arrives, skipping + /// notifications (e.g. agent/progress) and daemon-initiated requests. + fn response_for(&mut self, id: &str) -> Value { + loop { + let message = self.next_message(); + if message.get("method").is_some() { + continue; + } + assert_eq!( + message.get("id").and_then(Value::as_str), + Some(id), + "expected a response to {id}, got: {message}" + ); + return message; + } + } + + fn next_message(&mut self) -> Value { + match self.lines.recv_timeout(RESPONSE_TIMEOUT) { + Ok(line) => serde_json::from_str(&line).expect("daemon wrote invalid JSON"), + Err(RecvTimeoutError::Timeout) => panic!("timed out waiting for daemon output"), + Err(RecvTimeoutError::Disconnected) => { + panic!("daemon exited before writing a response") + } + } + } +} + +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn start_session_params() -> Value { + // An empty, dedicated cwd keeps the context indexer away from real + // machine state; "investigate" mode asks the backend for a lead card + // instead of entering the goal loop (whose patches need a live editor + // to answer editor/read_file validation requests). + let cwd = std::env::temp_dir().join(format!("loopbioticd-rpc-test-{}", std::process::id())); + std::fs::create_dir_all(&cwd).expect("create test cwd"); + json!({ + "cwd": cwd, + "file": "src/work.ts", + "cursor": {"line": 1, "column": 1}, + "selection": null, + "prompt": "payload is empty", + "mode": "investigate", + "buffer_text": "placeholder", + "buffer_start_line": 1, + "diagnostics": [], + }) +} + +#[test] +fn initialize_reports_protocol_version_without_client_params() { + let mut daemon = Daemon::spawn(); + + // Old clients send no client block at all; the handshake must still work. + let response = daemon.request("1", "initialize", json!({})); + + assert!( + response.get("error").is_none(), + "unexpected error: {response}" + ); + let result = &response["result"]; + assert_eq!(result["server"], json!("loopbioticd")); + assert_eq!(result["protocol_version"], json!(PROTOCOL_VERSION)); + assert_eq!(result["backend"]["name"], json!("mock")); +} + +#[test] +fn initialize_accepts_matching_client_protocol_version() { + let mut daemon = Daemon::spawn(); + + let response = daemon.request( + "1", + "initialize", + json!({ + "client": { + "name": "loopbiotic.nvim", + "protocol_version": PROTOCOL_VERSION, + }, + }), + ); + + assert!( + response.get("error").is_none(), + "unexpected error: {response}" + ); + assert_eq!( + response["result"]["protocol_version"], + json!(PROTOCOL_VERSION) + ); +} + +#[test] +fn initialize_rejects_mismatched_client_protocol_version() { + let mut daemon = Daemon::spawn(); + let client_version = PROTOCOL_VERSION + 1; + + let response = daemon.request( + "1", + "initialize", + json!({ + "client": { + "name": "loopbiotic.nvim", + "protocol_version": client_version, + }, + }), + ); + + assert!( + response.get("result").is_none(), + "expected an error: {response}" + ); + let error = &response["error"]; + assert_eq!(error["code"], json!(-32001)); + let message = error["message"].as_str().expect("error message"); + assert!( + message.contains("protocol version mismatch"), + "message should name the failure: {message}" + ); + assert!( + message.contains(&client_version.to_string()) + && message.contains(&PROTOCOL_VERSION.to_string()), + "message should include both versions: {message}" + ); + assert!( + message.to_lowercase().contains("update"), + "message should tell the user to update: {message}" + ); +} + +#[test] +fn session_start_returns_first_mock_card() { + let mut daemon = Daemon::spawn(); + + let init = daemon.request("1", "initialize", json!({})); + assert!(init.get("error").is_none(), "unexpected error: {init}"); + + let response = daemon.request("2", "session/start", start_session_params()); + + assert!( + response.get("error").is_none(), + "unexpected error: {response}" + ); + let result = &response["result"]; + assert!( + result["session_id"] + .as_str() + .is_some_and(|id| !id.is_empty()), + "missing session_id: {result}" + ); + // The mock backend opens investigations with its hypothesis card. + assert_eq!(result["card"]["kind"], json!("hypothesis")); + assert_eq!(result["card"]["title"], json!("Payload may be skipped")); + assert_eq!(result["goal"]["status"], json!("active")); + + // Following the lead keeps the same session alive and yields the next card. + let session_id = result["session_id"] + .as_str() + .expect("session id") + .to_owned(); + let follow = daemon.request( + "3", + "session/action", + json!({"session_id": session_id, "action": "follow"}), + ); + assert!(follow.get("error").is_none(), "unexpected error: {follow}"); + assert_eq!(follow["result"]["session_id"], json!(session_id)); + assert_eq!(follow["result"]["card"]["kind"], json!("finding")); +} + +#[test] +fn unknown_method_returns_error_response() { + let mut daemon = Daemon::spawn(); + + let response = daemon.request("1", "no/such_method", json!({})); + + assert!( + response.get("result").is_none(), + "expected an error: {response}" + ); + let error = &response["error"]; + assert_eq!(error["code"], json!(-32603)); + assert!( + error["message"] + .as_str() + .is_some_and(|message| message.contains("unknown method no/such_method")), + "unexpected message: {error}" + ); +} + +#[test] +fn stale_server_response_is_ignored_not_answered() { + let mut daemon = Daemon::spawn(); + + // A late reply to a daemon-initiated editor request (its granter already + // timed out) must be dropped, not answered with a parse/method error. + daemon.send(&json!({ + "jsonrpc": "2.0", + "id": "loopbioticd_999", + "result": {"granted": false}, + })); + daemon.send(&json!({ + "jsonrpc": "2.0", + "id": "after_stale", + "method": "initialize", + "params": {}, + })); + + // The very next daemon message must answer the initialize request; any + // reply to the stale line would show up first and fail this assertion. + let message = daemon.next_message(); + assert!( + message.get("method").is_none(), + "unexpected request: {message}" + ); + assert_eq!(message.get("id"), Some(&json!("after_stale"))); + assert!( + message.get("error").is_none(), + "unexpected error: {message}" + ); + assert_eq!( + message["result"]["protocol_version"], + json!(PROTOCOL_VERSION) + ); +} From 470313a2f16ccb92ca6a8f73017d048ef7a477f4 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:38:33 +0200 Subject: [PATCH 14/37] refactor: split engine into focused modules, degrade reachable panics to error cards --- rust/crates/loopbiotic_harness/src/engine.rs | 3282 ----------------- .../loopbiotic_harness/src/engine/goal.rs | 198 + .../loopbiotic_harness/src/engine/mod.rs | 763 ++++ .../src/engine/observations.rs | 161 + .../loopbiotic_harness/src/engine/prefetch.rs | 159 + .../loopbiotic_harness/src/engine/tests.rs | 1327 +++++++ .../loopbiotic_harness/src/engine/turn.rs | 524 +++ .../loopbiotic_harness/src/engine/validate.rs | 290 ++ 8 files changed, 3422 insertions(+), 3282 deletions(-) delete mode 100644 rust/crates/loopbiotic_harness/src/engine.rs create mode 100644 rust/crates/loopbiotic_harness/src/engine/goal.rs create mode 100644 rust/crates/loopbiotic_harness/src/engine/mod.rs create mode 100644 rust/crates/loopbiotic_harness/src/engine/observations.rs create mode 100644 rust/crates/loopbiotic_harness/src/engine/prefetch.rs create mode 100644 rust/crates/loopbiotic_harness/src/engine/tests.rs create mode 100644 rust/crates/loopbiotic_harness/src/engine/turn.rs create mode 100644 rust/crates/loopbiotic_harness/src/engine/validate.rs diff --git a/rust/crates/loopbiotic_harness/src/engine.rs b/rust/crates/loopbiotic_harness/src/engine.rs deleted file mode 100644 index 20e40ad..0000000 --- a/rust/crates/loopbiotic_harness/src/engine.rs +++ /dev/null @@ -1,3282 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; - -use anyhow::{Result, anyhow}; -use loopbiotic_backends::{ - BackendAction, BackendAdapter, BackendProgress, BackendRequest, BackendResponse, CardContract, - ProgressReporter, SessionSnapshot, -}; -use loopbiotic_context::ContextOptimizer; -use loopbiotic_patch::{PatchCoherence, PatchNormalizer, PatchValidator}; -use loopbiotic_protocol::{ - Action, ActionResult, AgentAttempt, Card, CardKind, ContextBundle, ErrorCard, GoalProgress, - MAX_GOAL_CHANGED_LINES, MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, Mode, ObservationKind, - ObservationProgress, PatchApplyResult, StartSessionParams, StartSessionResult, SummaryCard, - TokenUsage, -}; - -use crate::session::Session; -use crate::state::{NextState, SessionState}; - -pub struct Engine { - backend: Arc, - sessions: HashMap, - context_optimizer: ContextOptimizer, - prefetch_mode: PrefetchMode, - prefetches: HashMap, - location_granter: Option, - source_context_provider: Option, -} - -/// Most open_location grants honored within a single turn before the request -/// is surfaced as a deny card instead. -pub const MAX_LOCATION_GRANTS: usize = 2; - -/// Editor callback that asks the user to open a location mid-turn and, when -/// granted, returns freshly captured context for that buffer. -pub type LocationGranter = Arc< - dyn Fn( - loopbiotic_protocol::OpenLocationCard, - String, - ) - -> std::pin::Pin> + Send>> - + Send - + Sync, ->; - -/// Editor callback that snapshots a patch target without changing the active -/// window. Goal batches use it to validate every file before review begins. -pub type SourceContextProvider = Arc< - dyn Fn( - std::path::PathBuf, - String, - ) - -> std::pin::Pin> + Send>> - + Send - + Sync, ->; - -/// Speculative prefetch of the likely next card. `Fix` requests the patch -/// card in the background while the user is still reading a discovery card, -/// so pressing Fix returns (near-)instantly on a hit. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum PrefetchMode { - Off, - Fix, -} - -struct Prefetch { - action: Action, - fingerprint: u64, - handle: tokio::task::JoinHandle>, -} - -impl Engine { - pub fn new(backend: Arc) -> Self { - Self { - backend, - sessions: HashMap::new(), - context_optimizer: ContextOptimizer::default(), - prefetch_mode: PrefetchMode::Off, - prefetches: HashMap::new(), - location_granter: None, - source_context_provider: None, - } - } - - pub fn set_prefetch_mode(&mut self, mode: PrefetchMode) { - self.prefetch_mode = mode; - } - - pub fn set_location_granter(&mut self, granter: LocationGranter) { - self.location_granter = Some(granter); - } - - pub fn set_source_context_provider(&mut self, provider: SourceContextProvider) { - self.source_context_provider = Some(provider); - } - - pub async fn start(&mut self, params: StartSessionParams) -> Result { - self.start_with_progress(params, None).await - } - - pub async fn start_with_progress( - &mut self, - params: StartSessionParams, - progress: Option, - ) -> Result { - let mut session = Session::new(params.clone()); - let context = self.context_optimizer.optimize( - ContextBundle::from_start(params), - &session.original_prompt, - &session.context_policy, - ); - session.context = context.clone(); - let expected = expected_start_state(&session); - let response = self - .next_distinct_response( - &mut session, - BackendAction::Start, - context, - &expected, - progress, - None, - ) - .await; - let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); - let attempts = response.metadata.attempts.clone(); - let model = response.metadata.model.clone(); - self.add_usage(&mut session, &response.metadata.token_usage); - - let card = self.accept_response(&mut session, response, expected)?; - let session_id = session.id.clone(); - let goal = goal_progress(&session); - let token_usage = session.token_usage.clone(); - let context_report = session.context.report.clone(); - - self.sessions.insert(session_id.clone(), session); - self.schedule_prefetch(&session_id).await; - - Ok(StartSessionResult { - session_id, - card, - goal, - token_usage, - turn_token_usage, - context_report, - model, - attempts, - }) - } - - pub async fn action(&mut self, session_id: &str, action: Action) -> Result { - self.action_with_progress(session_id, action, None).await - } - - pub async fn action_with_progress( - &mut self, - session_id: &str, - action: Action, - progress: Option, - ) -> Result { - let mut session = self.take_session(session_id)?; - let prefetched = self.take_prefetch(&mut session, &action).await; - let result = self - .action_taken(session_id, &mut session, action, progress, prefetched) - .await; - - self.sessions.insert(session_id.into(), session); - if result.is_ok() { - self.schedule_prefetch(session_id).await; - } - - result - } - - pub async fn reply(&mut self, session_id: &str, text: String) -> Result { - self.reply_with_progress(session_id, text, None).await - } - - pub async fn reply_with_progress( - &mut self, - session_id: &str, - text: String, - progress: Option, - ) -> Result { - let mut session = self.take_session(session_id)?; - let result = self - .reply_taken(session_id, &mut session, text, progress) - .await; - - self.sessions.insert(session_id.into(), session); - if result.is_ok() { - self.schedule_prefetch(session_id).await; - } - - result - } - - async fn action_taken( - &self, - session_id: &str, - session: &mut Session, - action: Action, - progress: Option, - prefetched: Option, - ) -> Result { - if action == Action::ResumeDraft { - if session.state != SessionState::PatchExplained { - return Err(anyhow!("no explained patch is waiting to resume")); - } - let card = session - .cards - .iter() - .rev() - .find(|card| matches!(card, Card::Patch(_))) - .cloned() - .ok_or_else(|| anyhow!("pending patch is unavailable"))?; - session.state = SessionState::PatchShown; - session.cards.push(card.clone()); - - return Ok(ActionResult { - session_id: session_id.into(), - card, - goal: goal_progress(session), - token_usage: session.token_usage.clone(), - turn_token_usage: TokenUsage::default(), - context_report: session.context.report.clone(), - model: None, - attempts: vec![], - }); - } - - if session.state == SessionState::PatchShown - && matches!(action, Action::Retry | Action::EditPrompt) - { - session.pending_patch_cards.clear(); - } - - let state = session.state.next(&action)?; - if action == Action::Stop { - session.state = SessionState::Finished; - session.goal_status = loopbiotic_protocol::GoalStatus::Stopped; - session.next_step = None; - let card = session.stop_card(); - let token_usage = session.token_usage.clone(); - - session.cards.push(card.clone()); - - return Ok(ActionResult { - session_id: session_id.into(), - card, - goal: goal_progress(session), - token_usage, - turn_token_usage: Default::default(), - context_report: session.context.report.clone(), - model: None, - attempts: vec![], - }); - } - - let context = session.context.clone(); - - session.state = SessionState::Thinking; - - let response = self - .next_distinct_response( - session, - BackendAction::User(action), - context, - &state, - progress, - prefetched, - ) - .await; - - let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); - let attempts = response.metadata.attempts.clone(); - let model = response.metadata.model.clone(); - self.add_usage(session, &response.metadata.token_usage); - - let card = self.accept_response(session, response, state)?; - let token_usage = session.token_usage.clone(); - - Ok(ActionResult { - session_id: session_id.into(), - card, - goal: goal_progress(session), - token_usage, - turn_token_usage, - context_report: session.context.report.clone(), - model, - attempts, - }) - } - - async fn reply_taken( - &self, - session_id: &str, - session: &mut Session, - text: String, - progress: Option, - ) -> Result { - if text.trim().is_empty() { - return Err(anyhow!("reply is empty")); - } - - let context = session.context.clone(); - let expected = if session.state == SessionState::PatchExplained { - NextState::GoalWhy - } else if session.continuous_goal { - NextState::GoalLoop - } else { - NextState::Any - }; - - session.state = SessionState::Thinking; - - let response = self - .next_distinct_response( - session, - BackendAction::Reply(text), - context, - &expected, - progress, - None, - ) - .await; - - let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); - let attempts = response.metadata.attempts.clone(); - let model = response.metadata.model.clone(); - self.add_usage(session, &response.metadata.token_usage); - - let card = self.accept_response(session, response, expected)?; - let token_usage = session.token_usage.clone(); - - Ok(ActionResult { - session_id: session_id.into(), - card, - goal: goal_progress(session), - token_usage, - turn_token_usage, - context_report: session.context.report.clone(), - model, - attempts, - }) - } - - pub async fn apply_result(&mut self, result: PatchApplyResult) -> Result { - self.apply_result_with_progress(result, None).await - } - - pub async fn apply_result_with_progress( - &mut self, - result: PatchApplyResult, - progress: Option, - ) -> Result { - let mut session = self.take_session(&result.session_id)?; - let session_id = result.session_id.clone(); - let output = self - .apply_result_taken(&mut session, result, progress) - .await; - - self.sessions.insert(session_id, session); - - output - } - - async fn apply_result_taken( - &mut self, - session: &mut Session, - result: PatchApplyResult, - progress: Option, - ) -> Result { - session.state.require_patch()?; - validate_apply_result(session, &result)?; - self.context_optimizer - .invalidate(&result.context.cwd, &result.changed_files); - session.context = self.context_optimizer.optimize( - result.context.clone(), - &session.original_prompt, - &session.context_policy, - ); - let session_id = result.session_id.clone(); - - if result.accepted { - let completes_goal = matches!( - session.cards.last(), - Some(Card::Patch(card)) if card.goal_complete - ); - let completed_steps = completed_patch_steps(session); - let completed_step_signatures = completed_patch_signatures(session); - session.completed_steps.extend(completed_steps); - session - .completed_step_signatures - .extend(completed_step_signatures); - session.accepted_patches.extend(result.patch_ids.clone()); - session.state = SessionState::Summary; - session.goal_status = loopbiotic_protocol::GoalStatus::NeedsReview; - session.next_step = None; - if session.continuous_goal { - if let Some(next) = session.pending_patch_cards.pop_front() { - session.state = SessionState::PatchShown; - session.goal_status = loopbiotic_protocol::GoalStatus::Active; - session.next_step = Some(next.explanation.clone()); - let card = Card::Patch(next); - session.cards.push(card.clone()); - - return Ok(ActionResult { - session_id, - card, - goal: goal_progress(session), - token_usage: session.token_usage.clone(), - turn_token_usage: TokenUsage::default(), - context_report: session.context.report.clone(), - model: None, - attempts: vec![], - }); - } - if completes_goal { - return Ok(complete_goal_locally(&session_id, session)); - } - return self - .goal_turn_taken( - &session_id, - session, - BackendAction::User(Action::Next), - progress, - ) - .await; - } - let changed_files = result.changed_files; - let summary = if changed_files.is_empty() { - "The local patch was applied. Continue only if the goal needs another change." - .into() - } else { - format!( - "Applied the local patch to {}. Continue only if the goal needs another change.", - changed_files - .iter() - .map(|file| file.display().to_string()) - .collect::>() - .join(", ") - ) - }; - let card = Card::Summary(SummaryCard { - id: session.next_card_id("applied"), - title: "Local step applied".into(), - summary, - changed_files, - next_actions: vec![Action::Next, Action::RunCheck, Action::Stop], - }); - session.cards.push(card.clone()); - - return Ok(ActionResult { - session_id, - card, - goal: goal_progress(session), - token_usage: session.token_usage.clone(), - turn_token_usage: TokenUsage::default(), - context_report: session.context.report.clone(), - model: None, - attempts: vec![], - }); - } - - session.rejected_patches.extend(result.patch_ids.clone()); - session.pending_patch_cards.clear(); - session.state = SessionState::PatchShown; - - if session.continuous_goal { - return self - .goal_turn_taken( - &session_id, - session, - BackendAction::User(Action::Retry), - progress, - ) - .await; - } - - self.action_taken(&session_id, session, Action::Retry, progress, None) - .await - } - - async fn goal_turn_taken( - &self, - session_id: &str, - session: &mut Session, - action: BackendAction, - progress: Option, - ) -> Result { - let expected = NextState::GoalLoop; - let context = session.context.clone(); - session.state = SessionState::Thinking; - let response = self - .next_distinct_response(session, action, context, &expected, progress, None) - .await; - let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); - let attempts = response.metadata.attempts.clone(); - let model = response.metadata.model.clone(); - self.add_usage(session, &response.metadata.token_usage); - let card = self.accept_response(session, response, expected)?; - - Ok(ActionResult { - session_id: session_id.into(), - card, - goal: goal_progress(session), - token_usage: session.token_usage.clone(), - turn_token_usage, - context_report: session.context.report.clone(), - model, - attempts, - }) - } - - pub fn get(&self, session_id: &str) -> Option<&Session> { - self.sessions.get(session_id) - } - - pub fn update_context(&mut self, session_id: &str, context: ContextBundle) -> Result<()> { - let (prompt, policy) = self - .sessions - .get(session_id) - .map(|session| { - ( - session.original_prompt.clone(), - session.context_policy.clone(), - ) - }) - .ok_or_else(|| anyhow!("unknown session {session_id}"))?; - let context = self.context_optimizer.optimize(context, &prompt, &policy); - self.sessions - .get_mut(session_id) - .expect("session checked above") - .context = context; - - Ok(()) - } - - fn request( - &self, - session: &Session, - action: BackendAction, - context: ContextBundle, - expected: &NextState, - ) -> BackendRequest { - let expected_kind = expected_card_kind(session, &action, expected); - - let allow_goal_completion = matches!(expected, NextState::GoalLoop | NextState::GoalWhy); - let mut card_contract = CardContract { - expected_kind, - allow_goal_completion, - ..CardContract::default() - }; - if allow_goal_completion && !matches!(expected, NextState::GoalWhy) { - card_contract.max_patch_files = MAX_GOAL_PATCH_FILES; - card_contract.max_hunks_per_patch = MAX_GOAL_HUNKS_PER_PATCH; - card_contract.max_changed_lines = MAX_GOAL_CHANGED_LINES; - } - - BackendRequest { - session: SessionSnapshot { - id: session.id.clone(), - prompt: session.original_prompt.clone(), - completed_steps: session.completed_steps.clone(), - known_observations: session - .known_observations - .iter() - .map(observation_prompt_line) - .collect(), - mode: session.mode.clone(), - card_count: session.cards.len(), - last_card: session.cards.last().cloned(), - last_summary: session.cards.last().map(card_summary), - }, - action, - context, - card_contract, - } - } - - async fn next_distinct_response( - &self, - session: &mut Session, - action: BackendAction, - context: ContextBundle, - expected: &NextState, - progress: Option, - mut prefetched: Option, - ) -> BackendResponse { - let mut action = action; - let mut context = context; - let mut token_usage = None; - let mut attempts = Vec::new(); - let mut attempt = 0; - let mut grants = 0; - - while attempt < 3 { - let attempt_response = match prefetched.take() { - // A matching speculative response was computed for this exact - // request while the user was reading the previous card; it - // still goes through every dedup/validation gate below. - Some(response) => Ok(response), - None => { - let request = self.request(session, action, context.clone(), expected); - self.backend - .next_card_with_progress(request, progress.clone()) - .await - } - }; - let mut response = match attempt_response { - Ok(response) => response, - Err(error) => { - let detail = format!("{error:#}"); - let mut response = backend_failure_response(session, error); - merge_usage(&mut token_usage, &response.metadata.token_usage); - response.metadata.token_usage = token_usage; - attempts.push(AgentAttempt { - number: attempt + 1, - backend: response.metadata.backend.clone(), - outcome: "backend_error".into(), - token_usage: TokenUsage::default(), - detail: Some(detail), - candidate_card: None, - activities: vec![], - }); - response.metadata.attempts = attempts; - return response; - } - }; - let attempt_usage = response.metadata.token_usage.clone().unwrap_or_default(); - merge_usage(&mut token_usage, &response.metadata.token_usage); - - // A mid-turn permission request: the agent needs another file open - // before it can produce the real card. Ask the editor; on grant the - // same turn continues with the freshly captured context. Grants do - // not consume retry attempts. - if let Card::OpenLocation(request) = &response.card { - let request = request.clone(); - - if grants < MAX_LOCATION_GRANTS - && let Some(granter) = &self.location_granter - { - if let Some(progress) = &progress { - progress(BackendProgress { - session_id: session.id.clone(), - phase: "permission".into(), - message: format!( - "Agent asks to open {}", - request.location.file.display() - ), - }); - } - - if let Some(granted) = granter(request.clone(), session.id.clone()).await { - attempts.push(agent_attempt( - attempt + 1, - &response, - "location_granted", - Some(request.location.file.display().to_string()), - attempt_usage, - false, - )); - session.context = granted.clone(); - context = granted; - action = BackendAction::LocationGranted; - grants += 1; - continue; - } - } - - attempts.push(agent_attempt( - attempt + 1, - &response, - "location_declined", - Some(request.location.file.display().to_string()), - attempt_usage, - false, - )); - response.card = Card::Deny(loopbiotic_protocol::DenyCard { - id: session.next_card_id("deny"), - title: "Agent needs another file".into(), - reason: request.reason, - location: Some(request.location), - actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], - }); - response.metadata.token_usage = token_usage; - response.metadata.attempts = attempts; - return response; - } - - if !matches!(expected, NextState::GoalWhy) - && let Some((key, reason)) = duplicate_observation(session, &response.card) - { - activate_observation(session, &key); - attempts.push(agent_attempt( - attempt + 1, - &response, - if attempt < 2 { - "duplicate_retry" - } else { - "rejected" - }, - Some(reason.clone()), - attempt_usage, - true, - )); - if attempt < 2 { - if let Some(progress) = &progress { - progress(BackendProgress { - session_id: session.id.clone(), - phase: "deduplicating".into(), - message: "Retaining repeated context and requesting a distinct step" - .into(), - }); - } - action = BackendAction::ContractRetry(format!( - "{reason}. Return a distinct next observation; do not repeat known findings or signals." - )); - attempt += 1; - continue; - } - - let mut rejected = duplicate_failure_response(session, reason); - rejected.metadata.token_usage = token_usage; - rejected.metadata.attempts = attempts; - return rejected; - } - - if let Some(reason) = duplicate_completed_step(session, &response.card) { - attempts.push(agent_attempt( - attempt + 1, - &response, - if attempt < 2 { - "duplicate_step_retry" - } else { - "rejected" - }, - Some(reason.clone()), - attempt_usage, - true, - )); - if attempt < 2 { - if let Some(progress) = &progress { - progress(BackendProgress { - session_id: session.id.clone(), - phase: "deduplicating".into(), - message: "Rejecting a repeated patch step".into(), - }); - } - action = BackendAction::ContractRetry(format!( - "{reason}. Draft a materially different unresolved requirement. Do not merely rename, extract, or rephrase the accepted step. If this location is already resolved, return open_location for the actual next target or deny instead of inventing another patch here." - )); - attempt += 1; - continue; - } - - let mut rejected = duplicate_failure_response(session, reason); - rejected.metadata.token_usage = token_usage; - rejected.metadata.attempts = attempts; - return rejected; - } - - let mut candidate = response.card.clone(); - let validation = if matches!(expected, NextState::GoalLoop) { - self.normalize_goal_batch(&mut candidate, &context, &session.id) - .await - } else { - PatchNormalizer::normalize_card(&mut candidate, &context) - .and_then(|()| validate_backend_card(&candidate, expected, &context)) - } - .map(|()| PatchCoherence::annotate(&mut candidate)); - if let Err(error) = validation { - let detail = error.to_string(); - attempts.push(agent_attempt( - attempt + 1, - &response, - if attempt < 2 { - "contract_retry" - } else { - "rejected" - }, - Some(detail.clone()), - attempt_usage, - true, - )); - if attempt < 2 { - if let Some(progress) = &progress { - progress(BackendProgress { - session_id: session.id.clone(), - phase: "repairing".into(), - message: "Patch contract failed; Codex is repairing the local step" - .into(), - }); - } - let instruction = if matches!(expected, NextState::GoalLoop) { - "Re-read every affected file with read-only tools and return the corrected complete multi-file batch. Context/remove lines must be exact and contiguous in each corresponding source. Do not split the goal into another model turn; use open_location only if a required source cannot be inspected." - } else { - "Rebuild the same step. Source context/remove lines must be exact and contiguous in the supplied buffer; added lines do not replace omitted source context. The resulting local step must remain type-correct without work deferred to a later card. If the change belongs in a different file than the supplied buffer, return an open_location op with that place instead of another patch." - }; - action = BackendAction::ContractRetry(format!( - "The previous card failed the local patch contract: {detail}. {instruction}" - )); - attempt += 1; - continue; - } - - response.card = - rejected_card(session, &candidate, error, response.raw_output.as_deref()); - response.metadata.token_usage = token_usage; - response.metadata.attempts = attempts; - return response; - } - - response.card = candidate; - attempts.push(agent_attempt( - attempt + 1, - &response, - "accepted", - None, - attempt_usage, - false, - )); - response.metadata.token_usage = token_usage; - response.metadata.attempts = attempts; - return response; - } - - unreachable!() - } - - async fn normalize_goal_batch( - &self, - candidate: &mut Card, - current: &ContextBundle, - session_id: &str, - ) -> Result<()> { - if !matches!(candidate, Card::Patch(_)) { - return validate_backend_card(candidate, &NextState::GoalLoop, current); - } - - validate_one_card(candidate)?; - // Correct miscounted hunk headers before the count check rejects them. - PatchNormalizer::normalize_hunk_headers(candidate)?; - PatchValidator::validate_card_with_limits( - candidate, - MAX_GOAL_PATCH_FILES, - MAX_GOAL_HUNKS_PER_PATCH, - MAX_GOAL_CHANGED_LINES, - )?; - let Card::Patch(card) = candidate else { - unreachable!(); - }; - - for index in 0..card.patches.len() { - let file = card.patches[index].file.clone(); - let source = if let Some(provider) = &self.source_context_provider { - provider(file.clone(), session_id.to_string()).await - } else if context_targets(current, &file) { - Some(current.clone()) - } else { - None - } - .ok_or_else(|| anyhow!("editor source is unavailable for {}", file.display()))?; - - if !context_targets(&source, &file) { - return Err(anyhow!( - "editor returned {} while validating {}", - source.file.display(), - file.display() - )); - } - - let mut single = Card::Patch(loopbiotic_protocol::PatchCard { - id: card.id.clone(), - title: card.title.clone(), - explanation: card.explanation.clone(), - warnings: vec![], - goal_complete: card.goal_complete, - patches: vec![card.patches[index].clone()], - actions: card.actions.clone(), - }); - PatchNormalizer::normalize_card(&mut single, &source) - .map_err(|error| anyhow!("{}: {error}", file.display()))?; - PatchValidator::validate_card_against_context(&single, &source) - .map_err(|error| anyhow!("{}: {error}", file.display()))?; - - let Card::Patch(single) = single else { - unreachable!(); - }; - card.patches[index] = single.patches.into_iter().next().unwrap(); - } - - NextState::GoalLoop.validate(candidate) - } - - fn accept_response( - &self, - session: &mut Session, - response: BackendResponse, - next_state: NextState, - ) -> Result { - let mut received = response.card; - if let Card::Patch(patch) = &mut received - && !patch.actions.contains(&Action::Why) - { - patch.actions.insert(1, Action::Why); - } - if matches!(next_state, NextState::GoalWhy) - && let Card::Finding(finding) = &mut received - { - finding.actions = vec![Action::ResumeDraft, Action::Stop]; - } - if matches!(next_state, NextState::GoalLoop) - && let Card::Summary(summary) = &mut received - { - summary - .next_actions - .retain(|action| *action != Action::Next); - if !summary.next_actions.contains(&Action::RunCheck) { - summary.next_actions.insert(0, Action::RunCheck); - } - if !summary.next_actions.contains(&Action::Stop) { - summary.next_actions.push(Action::Stop); - } - } - if !matches!(next_state, NextState::GoalWhy) { - prepare_observation_card(session, &mut received); - } - PatchCoherence::annotate(&mut received); - if !matches!(next_state, NextState::GoalWhy) { - record_observations(session, &received); - } - let card = if matches!(next_state, NextState::GoalLoop) { - queue_goal_patch_cards(session, received)? - } else { - received - }; - - update_goal_state(session, &card, &next_state); - session.state = state_after_card(&card, &next_state); - session.cards.push(card.clone()); - - Ok(card) - } - - fn take_session(&mut self, session_id: &str) -> Result { - self.sessions - .remove(session_id) - .ok_or_else(|| anyhow!("unknown session {session_id}")) - } - - fn add_usage(&self, session: &mut Session, usage: &Option) { - if let Some(usage) = usage { - session.token_usage.add(usage); - } - } - - /// Requests the likely next card in the background while the user reads - /// the one just shown. Only Fix is predicted: it is the most common and - /// slowest follow-up, and on backends with a separate patch process a - /// misprediction never blocks the user's real next request. - async fn schedule_prefetch(&mut self, session_id: &str) { - if self.prefetch_mode != PrefetchMode::Fix { - return; - } - - if let Some(existing) = self.prefetches.get(session_id) { - if !existing.handle.is_finished() { - // An earlier speculation is still running on the backend; - // queueing another would only pile up turns. - return; - } - // Fold the finished-but-unconsumed speculation into the session's - // token totals so wasted turns stay visible to the user. - if let Some(stale) = self.prefetches.remove(session_id) - && let Ok(Ok(response)) = stale.handle.await - && let Some(session) = self.sessions.get_mut(session_id) - { - fold_usage(session, &response.metadata.token_usage); - } - } - - let Some(session) = self.sessions.get(session_id) else { - return; - }; - if session.state != SessionState::CardShown { - return; - } - let Some(card) = session.cards.last() else { - return; - }; - if !card.actions().contains(&Action::Fix) { - return; - } - let Ok(expected) = session.state.next(&Action::Fix) else { - return; - }; - - let request = self.request( - session, - BackendAction::User(Action::Fix), - session.context.clone(), - &expected, - ); - let fingerprint = request_fingerprint(&request); - let backend = self.backend.clone(); - let handle = tokio::spawn(async move { backend.next_card(request).await }); - - self.prefetches.insert( - session_id.to_string(), - Prefetch { - action: Action::Fix, - fingerprint, - handle, - }, - ); - } - - /// Consumes a pending speculation if it was computed for exactly the - /// request this action would produce; otherwise leaves the real path - /// untouched and keeps the wasted tokens accounted for. - async fn take_prefetch( - &mut self, - session: &mut Session, - action: &Action, - ) -> Option { - let entry = self.prefetches.remove(&session.id)?; - - if entry.action == *action - && let Ok(expected) = session.state.next(action) - { - let request = self.request( - session, - BackendAction::User(action.clone()), - session.context.clone(), - &expected, - ); - if request_fingerprint(&request) == entry.fingerprint { - return match entry.handle.await { - Ok(Ok(response)) => Some(response), - _ => None, - }; - } - if entry.handle.is_finished() { - if let Ok(Ok(response)) = entry.handle.await { - fold_usage(session, &response.metadata.token_usage); - } - return None; - } - self.prefetches.insert(session.id.clone(), entry); - return None; - } - - if entry.handle.is_finished() { - if let Ok(Ok(response)) = entry.handle.await { - fold_usage(session, &response.metadata.token_usage); - } - } else { - self.prefetches.insert(session.id.clone(), entry); - } - - None - } -} - -fn fold_usage(session: &mut Session, usage: &Option) { - if let Some(usage) = usage { - session.token_usage.add(usage); - } -} - -fn request_fingerprint(request: &BackendRequest) -> u64 { - use std::hash::{DefaultHasher, Hash, Hasher}; - - // Only model-visible data may decide whether a speculative response - // matches: the optimizer report (cache counters vary run to run) and raw - // LSP hints are telemetry that backend_context strips before the model - // ever sees them. - let mut request = request.clone(); - request.context.report = None; - request.context.hints = vec![]; - - let mut hasher = DefaultHasher::new(); - serde_json::to_string(&request) - .unwrap_or_default() - .hash(&mut hasher); - hasher.finish() -} - -fn agent_attempt( - number: usize, - response: &BackendResponse, - outcome: &str, - detail: Option, - token_usage: TokenUsage, - include_candidate: bool, -) -> AgentAttempt { - AgentAttempt { - number, - backend: response.metadata.backend.clone(), - outcome: outcome.into(), - token_usage, - detail, - candidate_card: include_candidate.then(|| response.card.clone()), - activities: response.metadata.activities.clone(), - } -} - -fn goal_progress(session: &Session) -> GoalProgress { - GoalProgress { - statement: session.original_prompt.clone(), - completed_steps: session.completed_steps.clone(), - known_observations: session.known_observations.clone(), - status: session.goal_status, - next_step: session.next_step.clone(), - } -} - -fn complete_goal_locally(session_id: &str, session: &mut Session) -> ActionResult { - let mut changed_files = session - .completed_step_signatures - .iter() - .map(|(file, _)| file.clone()) - .collect::>(); - changed_files.sort(); - changed_files.dedup(); - - session.state = SessionState::Summary; - session.goal_status = loopbiotic_protocol::GoalStatus::Complete; - session.next_step = None; - let card = Card::Summary(SummaryCard { - id: session.next_card_id("complete"), - title: "Goal complete".into(), - summary: format!( - "Completed {} reviewed change{} for: {}", - session.completed_steps.len(), - if session.completed_steps.len() == 1 { - "" - } else { - "s" - }, - session.original_prompt - ), - changed_files, - next_actions: vec![Action::RunCheck, Action::Stop], - }); - session.cards.push(card.clone()); - - ActionResult { - session_id: session_id.into(), - card, - goal: goal_progress(session), - token_usage: session.token_usage.clone(), - turn_token_usage: TokenUsage::default(), - context_report: session.context.report.clone(), - model: None, - attempts: vec![], - } -} - -fn update_goal_state(session: &mut Session, card: &Card, next_state: &NextState) { - if !matches!(next_state, NextState::GoalLoop) { - return; - } - - match card { - Card::Patch(card) => { - session.goal_status = loopbiotic_protocol::GoalStatus::Active; - session.next_step = Some(card.explanation.clone()); - } - Card::Summary(_) => { - session.goal_status = loopbiotic_protocol::GoalStatus::Complete; - session.next_step = None; - } - Card::Finding(card) => { - session.goal_status = loopbiotic_protocol::GoalStatus::Active; - session.next_step = Some(card.finding.clone()); - } - Card::Hypothesis(card) => { - session.goal_status = loopbiotic_protocol::GoalStatus::Active; - session.next_step = Some(card.claim.clone()); - } - Card::Choice(_) => { - session.goal_status = loopbiotic_protocol::GoalStatus::Active; - session.next_step = None; - } - _ => {} - } -} - -fn merge_usage( - total: &mut Option, - turn: &Option, -) { - let Some(turn) = turn else { - return; - }; - - if let Some(total) = total { - total.add(turn); - } else { - *total = Some(turn.clone()); - } -} - -fn duplicate_observation(session: &Session, card: &Card) -> Option<(String, String)> { - let (key, _, _) = core_observation(card)?; - session - .observation_index - .contains_key(&key) - .then(|| (key, "backend repeated a retained observation".into())) -} - -fn duplicate_completed_step(session: &Session, card: &Card) -> Option { - let Card::Patch(card) = card else { - return None; - }; - - card.patches.iter().find_map(|patch| { - let candidate = normalize_step(&patch.explanation); - session - .completed_step_signatures - .iter() - .filter(|(file, _)| file == &patch.file) - .find(|(_, completed)| step_similarity(completed, &candidate) >= 0.72) - .map(|_| { - format!( - "backend proposed a patch semantically overlapping an accepted step in {}", - patch.file.display() - ) - }) - }) -} - -fn normalize_step(text: &str) -> String { - const STOP_WORDS: &[&str] = &[ - "add", "adds", "and", "dla", "dodaje", "do", "for", "from", "into", "oraz", "the", "this", - "that", "to", "use", "uses", "with", "zmienia", - ]; - - normalize_observation(text) - .split_whitespace() - .filter(|word| word.chars().count() > 2 && !STOP_WORDS.contains(word)) - .collect::>() - .join(" ") -} - -fn step_similarity(left: &str, right: &str) -> f32 { - let left = left - .split_whitespace() - .collect::>(); - let right = right - .split_whitespace() - .collect::>(); - if left.is_empty() || right.is_empty() { - return 0.0; - } - - let shared = left.intersection(&right).count() as f32; - shared / left.len().min(right.len()) as f32 -} - -fn prepare_observation_card(session: &mut Session, card: &mut Card) { - if core_observation(card).is_some() { - for observation in &mut session.known_observations { - observation.active = false; - } - } - - match card { - Card::Hypothesis(card) => { - if let Some(evidence) = &mut card.evidence - && !evidence.annotation.trim().is_empty() - { - let key = observation_key(ObservationKind::Signal, &evidence.annotation); - if session.observation_index.contains_key(&key) { - activate_observation(session, &key); - evidence.annotation.clear(); - } - } - } - Card::Finding(card) => { - if let Some(annotation) = card - .annotation - .clone() - .filter(|text| !text.trim().is_empty()) - { - let key = observation_key(ObservationKind::Signal, &annotation); - if session.observation_index.contains_key(&key) { - activate_observation(session, &key); - card.annotation = None; - } - } - } - _ => {} - } -} - -fn record_observations(session: &mut Session, card: &Card) { - if let Some((key, kind, label)) = core_observation(card) { - record_observation(session, key, kind, label); - } - - match card { - Card::Hypothesis(card) => { - if let Some(evidence) = &card.evidence - && !evidence.annotation.trim().is_empty() - { - let label = evidence.annotation.clone(); - record_observation( - session, - observation_key(ObservationKind::Signal, &label), - ObservationKind::Signal, - label, - ); - } - } - Card::Finding(card) => { - if let Some(label) = card - .annotation - .clone() - .filter(|text| !text.trim().is_empty()) - { - record_observation( - session, - observation_key(ObservationKind::Signal, &label), - ObservationKind::Signal, - label, - ); - } - } - _ => {} - } -} - -fn core_observation(card: &Card) -> Option<(String, ObservationKind, String)> { - let (kind, label) = match card { - Card::Hypothesis(card) => (ObservationKind::Hypothesis, card.claim.clone()), - Card::Finding(card) => (ObservationKind::Finding, card.finding.clone()), - _ => return None, - }; - - Some((observation_key(kind, &label), kind, label)) -} - -fn record_observation(session: &mut Session, key: String, kind: ObservationKind, label: String) { - if let Some(index) = session.observation_index.get(&key).copied() { - if let Some(observation) = session.known_observations.get_mut(index) { - observation.occurrences += 1; - observation.active = true; - } - return; - } - - let index = session.known_observations.len(); - session.observation_index.insert(key, index); - session.known_observations.push(ObservationProgress { - id: format!("o_{}", index + 1), - kind, - label, - occurrences: 1, - active: true, - }); -} - -fn activate_observation(session: &mut Session, key: &str) { - let Some(index) = session.observation_index.get(key).copied() else { - return; - }; - if let Some(observation) = session.known_observations.get_mut(index) { - observation.occurrences += 1; - observation.active = true; - } -} - -fn observation_key(kind: ObservationKind, label: &str) -> String { - format!( - "{}:{}", - observation_kind_name(kind), - normalize_observation(label) - ) -} - -fn normalize_observation(text: &str) -> String { - text.to_lowercase() - .chars() - .map(|character| { - if character.is_alphanumeric() { - character - } else { - ' ' - } - }) - .collect::() - .split_whitespace() - .collect::>() - .join(" ") -} - -fn observation_kind_name(kind: ObservationKind) -> &'static str { - match kind { - ObservationKind::Hypothesis => "hypothesis", - ObservationKind::Finding => "finding", - ObservationKind::Signal => "signal", - } -} - -fn observation_prompt_line(observation: &ObservationProgress) -> String { - format!( - "{} {} (seen {}x): {}", - observation.id, - observation_kind_name(observation.kind), - observation.occurrences, - observation.label - ) -} - -fn validate_backend_card( - card: &Card, - next_state: &NextState, - context: &ContextBundle, -) -> Result<()> { - // Backend errors must reach the editor unchanged instead of being replaced by - // a generic state-machine error such as "expected patch card". - if matches!(card, Card::Error(_)) { - return Ok(()); - } - - // A denial is valid in any state: the agent is telling the user it cannot - // produce the expected card, so only the card text itself is checked. - if matches!(card, Card::Deny(_)) { - return validate_one_card(card); - } - - validate_one_card(card)?; - if matches!(next_state, NextState::GoalLoop) { - PatchValidator::validate_card_with_limits( - card, - MAX_GOAL_PATCH_FILES, - MAX_GOAL_HUNKS_PER_PATCH, - MAX_GOAL_CHANGED_LINES, - )?; - } else { - PatchValidator::validate_card(card)?; - } - validate_patch_target(card, context)?; - PatchValidator::validate_card_against_context(card, context)?; - next_state.validate(card)?; - - Ok(()) -} - -fn validate_patch_target(card: &Card, context: &ContextBundle) -> Result<()> { - let Card::Patch(card) = card else { - return Ok(()); - }; - let expected = if context.file.is_absolute() { - context - .file - .strip_prefix(&context.cwd) - .unwrap_or(&context.file) - } else { - &context.file - }; - - if let Some(patch) = card.patches.first() - && patch.file != expected - { - return Err(anyhow!( - "patch targets {}, but the accepted source location is {}; open that location before Fix", - patch.file.display(), - expected.display() - )); - } - - Ok(()) -} - -fn context_targets(context: &ContextBundle, file: &std::path::Path) -> bool { - let actual = if context.file.is_absolute() { - context - .file - .strip_prefix(&context.cwd) - .unwrap_or(&context.file) - } else { - &context.file - }; - - actual == file -} - -fn validate_one_card(card: &Card) -> Result<()> { - if card.id().trim().is_empty() { - return Err(anyhow!("card id is empty")); - } - - match card { - Card::Hypothesis(card) => { - require_text("card title", &card.title)?; - require_text("hypothesis claim", &card.claim)?; - if let Some(location) = &card.evidence { - validate_location( - &location.file, - location.line, - location.column, - "hypothesis evidence", - )?; - } - if let Some(loopbiotic_protocol::NextMove::OpenLocation(location)) = &card.next_move { - validate_location( - &location.file, - location.line, - location.column, - "hypothesis next move", - )?; - } - } - Card::Finding(card) => { - require_text("card title", &card.title)?; - require_text("finding", &card.finding)?; - if let Some(location) = &card.location { - validate_location( - &location.file, - location.line, - location.column, - "finding location", - )?; - } - } - Card::Patch(card) => { - require_text("card title", &card.title)?; - require_text("patch explanation", &card.explanation)?; - for patch in &card.patches { - require_text("file patch explanation", &patch.explanation)?; - } - } - Card::Choice(card) => { - require_text("card title", &card.title)?; - require_text("choice question", &card.question)?; - if card.options.is_empty() { - return Err(anyhow!("choice card has no options")); - } - for option in &card.options { - require_text("choice option id", &option.id)?; - require_text("choice option label", &option.label)?; - } - } - Card::Deny(card) => { - require_text("card title", &card.title)?; - require_text("deny reason", &card.reason)?; - if let Some(location) = &card.location { - validate_location( - &location.file, - location.line, - location.column, - "deny location", - )?; - } - } - Card::OpenLocation(card) => { - require_text("open_location reason", &card.reason)?; - validate_location( - &card.location.file, - card.location.line, - card.location.column, - "open_location", - )?; - } - Card::Summary(card) => { - require_text("card title", &card.title)?; - require_text("summary", &card.summary)?; - } - Card::Error(card) => { - require_text("card title", &card.title)?; - require_text("error message", &card.message)?; - } - } - - if !matches!(card, Card::Choice(_) | Card::Summary(_)) && card.actions().is_empty() { - return Err(anyhow!("card has no actions")); - } - - Ok(()) -} - -fn require_text(field: &str, value: &str) -> Result<()> { - if value.trim().is_empty() { - return Err(anyhow!("{field} is empty")); - } - - Ok(()) -} - -fn validate_location( - file: &std::path::Path, - line: usize, - column: usize, - label: &str, -) -> Result<()> { - if file.as_os_str().is_empty() { - return Err(anyhow!("{label} file is empty")); - } - if line == 0 || column == 0 { - return Err(anyhow!("{label} line and column must start at 1")); - } - - Ok(()) -} - -fn validate_apply_result(session: &Session, result: &PatchApplyResult) -> Result<()> { - let Some(Card::Patch(card)) = session.cards.last() else { - return Err(anyhow!("patch state has no current patch card")); - }; - - if result.card_id != card.id { - return Err(anyhow!( - "apply result targets card {}, but current patch card is {}", - result.card_id, - card.id - )); - } - - let expected_patch_ids = card - .patches - .iter() - .map(|patch| patch.id.clone()) - .collect::>(); - if result.patch_ids != expected_patch_ids { - return Err(anyhow!( - "apply result patch ids do not match the current patch card" - )); - } - - let expected_files = card - .patches - .iter() - .map(|patch| patch.file.clone()) - .collect::>(); - if result.accepted && result.changed_files != expected_files { - return Err(anyhow!( - "accepted apply result changed files do not match the current patch card" - )); - } - if !result.accepted && !result.changed_files.is_empty() { - return Err(anyhow!( - "rejected apply result cannot contain changed files" - )); - } - - Ok(()) -} - -fn completed_patch_steps(session: &Session) -> Vec { - let Some(Card::Patch(card)) = session.cards.last() else { - return vec![]; - }; - - card.patches - .iter() - .map(|patch| format!("{}: {}", patch.file.display(), patch.explanation)) - .collect() -} - -fn queue_goal_patch_cards(session: &mut Session, card: Card) -> Result { - let Card::Patch(card) = card else { - session.pending_patch_cards.clear(); - return Ok(card); - }; - - let mut cards = Vec::new(); - for patch in card.patches { - let diff = loopbiotic_patch::UnifiedDiff::parse(&patch.diff)?; - let hunk_count = diff.hunks.len(); - for (index, hunk) in diff.hunks.into_iter().enumerate() { - let suffix = if hunk_count == 1 { - String::new() - } else { - format!(" ({}/{hunk_count})", index + 1) - }; - let explanation = if hunk_count == 1 { - patch.explanation.clone() - } else { - format!("{} Hunk {}/{}.", patch.explanation, index + 1, hunk_count) - }; - cards.push(loopbiotic_protocol::PatchCard { - id: format!("{}_h{}", card.id, cards.len() + 1), - title: format!("{}{}", card.title, suffix), - explanation: explanation.clone(), - warnings: card.warnings.clone(), - goal_complete: false, - patches: vec![loopbiotic_protocol::FilePatch { - id: format!("{}_h{}", patch.id, index + 1), - file: patch.file.clone(), - diff: loopbiotic_patch::UnifiedDiff { hunks: vec![hunk] }.render(), - explanation, - }], - actions: card.actions.clone(), - }); - } - } - - let mut cards = cards.into_iter(); - let mut first = cards - .next() - .ok_or_else(|| anyhow!("goal patch contains no reviewable hunks"))?; - let mut pending = cards.collect::>(); - if card.goal_complete { - if let Some(last) = pending.back_mut() { - last.goal_complete = true; - } else { - first.goal_complete = true; - } - } - session.pending_patch_cards = pending; - - Ok(Card::Patch(first)) -} - -fn completed_patch_signatures(session: &Session) -> Vec<(std::path::PathBuf, String)> { - let Some(Card::Patch(card)) = session.cards.last() else { - return vec![]; - }; - - card.patches - .iter() - .map(|patch| (patch.file.clone(), normalize_step(&patch.explanation))) - .collect() -} - -fn rejected_card( - session: &Session, - received: &Card, - error: anyhow::Error, - raw_output: Option<&str>, -) -> Card { - let mut message = format!("{error}\nReceived card kind: {:?}.", received.kind()); - - if let Some(raw_output) = raw_output - .map(str::trim) - .filter(|output| !output.is_empty()) - { - let raw_output = raw_output.chars().take(1_200).collect::(); - message.push_str("\n\nRaw backend response:\n"); - message.push_str(&raw_output); - } - - Card::Error(ErrorCard { - id: session.next_card_id("rejected"), - title: "Backend card rejected".into(), - message, - actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], - }) -} - -fn backend_failure_response(session: &Session, error: anyhow::Error) -> BackendResponse { - BackendResponse { - card: Card::Error(ErrorCard { - id: session.next_card_id("backend_error"), - title: "Backend request failed".into(), - message: format!("{error:#}"), - actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], - }), - raw_output: None, - metadata: loopbiotic_backends::BackendMetadata { - backend: "harness".into(), - model: None, - token_usage: None, - activities: vec![], - attempts: vec![], - }, - } -} - -fn duplicate_failure_response(session: &Session, reason: String) -> BackendResponse { - BackendResponse { - card: Card::Error(ErrorCard { - id: session.next_card_id("duplicate_error"), - title: "Backend repeated retained context".into(), - message: format!( - "{reason}. The duplicate was retained in session memory but was not shown again." - ), - actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], - }), - raw_output: None, - metadata: loopbiotic_backends::BackendMetadata { - backend: "harness".into(), - model: None, - token_usage: None, - activities: vec![], - attempts: vec![], - }, - } -} - -fn expected_start_state(session: &Session) -> NextState { - if session.continuous_goal { - NextState::GoalLoop - } else if session.mode == Mode::Fix { - NextState::Patch - } else { - NextState::Any - } -} - -/// None means the agent may answer with whichever card kind fits, including a -/// clarifying choice or a deny. A kind is only demanded when the user asked -/// for one (a "/{kind}" prompt prefix, an explicit mode, or a concrete action -/// such as Fix) or when the state machine requires it. -fn expected_card_kind( - session: &Session, - action: &BackendAction, - next_state: &NextState, -) -> Option { - match next_state { - NextState::Patch => return Some(CardKind::Patch), - NextState::GoalLoop => return None, - NextState::GoalWhy => return Some(CardKind::Finding), - NextState::Summary | NextState::Finished => return Some(CardKind::Summary), - NextState::Any | NextState::Card => {} - } - - match action { - BackendAction::Start => session.forced_kind.or(match session.mode { - Mode::Fix | Mode::Propose => Some(CardKind::Patch), - Mode::Explain | Mode::Review => Some(CardKind::Finding), - Mode::Investigate => Some(CardKind::Hypothesis), - Mode::Auto => None, - }), - BackendAction::Reply(_) => None, - BackendAction::ContractRetry(_) => None, - BackendAction::LocationGranted => None, - BackendAction::User(action) => match action { - Action::Fix => Some(CardKind::Patch), - Action::OtherLead => Some(CardKind::Hypothesis), - Action::Follow | Action::Why | Action::Open | Action::RunCheck | Action::Next => { - Some(CardKind::Finding) - } - Action::Retry | Action::EditPrompt => session - .cards - .iter() - .rev() - .find(|card| !matches!(card, Card::Error(_) | Card::Deny(_))) - .map(Card::kind) - .or(session.forced_kind), - Action::Apply | Action::ApplyPatch { .. } | Action::ResumeDraft | Action::Stop => { - Some(CardKind::Summary) - } - }, - } -} - -fn state_after_card(card: &Card, next_state: &NextState) -> SessionState { - let refused = matches!(card, Card::Error(_) | Card::Deny(_)); - if refused && matches!(next_state, NextState::Patch) { - return SessionState::PatchFailed; - } - if refused && matches!(next_state, NextState::GoalLoop) { - return SessionState::GoalLoopFailed; - } - if refused && matches!(next_state, NextState::GoalWhy) { - return SessionState::PatchExplained; - } - if matches!(next_state, NextState::GoalWhy) && matches!(card, Card::Finding(_)) { - return SessionState::PatchExplained; - } - - SessionState::from_card(card) -} - -fn card_summary(card: &Card) -> String { - match card { - Card::Hypothesis(card) => format!("hypothesis: {}", card.claim), - Card::Finding(card) => format!("finding: {}", card.finding), - Card::Patch(card) => format!("patch: {}", card.explanation), - Card::Choice(card) => format!("choice: {}", card.question), - Card::Deny(card) => format!("deny: {}", card.reason), - Card::OpenLocation(card) => format!("open_location: {}", card.reason), - Card::Summary(card) => format!("summary: {}", card.summary), - Card::Error(card) => format!("error: {}", card.message), - } -} - -impl Session { - fn stop_card(&self) -> Card { - Card::Summary(SummaryCard { - id: self.next_card_id("stop"), - title: "Stopped".into(), - summary: "Session stopped.".into(), - changed_files: vec![], - next_actions: vec![], - }) - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - - use async_trait::async_trait; - use loopbiotic_backends::{ - BackendAction, BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, - MockBackend, - }; - use loopbiotic_protocol::{ - BackendInfo, Cursor, FilePatch, FindingCard, HypothesisCard, Mode, PatchCard, - }; - - use super::*; - - fn params() -> StartSessionParams { - StartSessionParams { - cwd: PathBuf::from("/tmp/project"), - file: PathBuf::from("src/work.ts"), - cursor: Cursor { line: 1, column: 1 }, - selection: None, - prompt: "payload is empty".into(), - mode: Mode::Investigate, - buffer_text: "placeholder".into(), - buffer_start_line: 1, - diagnostics: vec![], - hints: vec![], - context_policy: Default::default(), - } - } - - fn editor_context(buffer_text: &str) -> ContextBundle { - ContextBundle { - cwd: PathBuf::from("/tmp/project"), - file: PathBuf::from("src/work.ts"), - cursor: Cursor { line: 1, column: 1 }, - selection: None, - buffer_text: buffer_text.into(), - buffer_start_line: 1, - diagnostics: vec![], - hints: vec![], - artifacts: vec![], - report: None, - } - } - - #[test] - fn detects_rephrased_completed_patch_in_the_same_file() { - let mut session = Session::new(params()); - session.completed_step_signatures.push(( - PathBuf::from("src/work.ts"), - normalize_step("Extract payload validation into a local helper"), - )); - let card = Card::Patch(PatchCard { - id: "c_repeat".into(), - title: "Extract validation".into(), - explanation: "Keep validation local.".into(), - warnings: vec![], - goal_complete: false, - patches: vec![FilePatch { - id: "p_repeat".into(), - file: PathBuf::from("src/work.ts"), - diff: "@@ -1,1 +1,1 @@\n-old\n+new\n".into(), - explanation: "Extract the local helper used for payload validation".into(), - }], - actions: vec![Action::Apply], - }); - - assert!(duplicate_completed_step(&session, &card).is_some()); - } - - #[tokio::test] - async fn starts_with_hypothesis() { - let backend = Arc::new(MockBackend); - let mut engine = Engine::new(backend); - - let result = engine.start(params()).await.unwrap(); - - assert!(matches!(result.card, Card::Hypothesis(_))); - } - - #[tokio::test] - async fn rejects_apply_before_patch() { - let backend = Arc::new(MockBackend); - let mut engine = Engine::new(backend); - let result = engine.start(params()).await.unwrap(); - - let error = engine - .action(&result.session_id, Action::Apply) - .await - .unwrap_err(); - - assert!(error.to_string().contains("invalid action")); - } - - #[tokio::test] - async fn continuous_goal_reviews_a_complete_batch_without_more_model_turns() { - let backend = Arc::new(BatchGoalBackend::default()); - let mut engine = Engine::new(backend.clone()); - let mut goal = params(); - goal.mode = Mode::Auto; - goal.buffer_text = "first\nmiddle\nlast".into(); - let first = engine.start(goal).await.unwrap(); - - let Card::Patch(first_patch) = &first.card else { - panic!("expected first review hunk"); - }; - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - - let result = PatchApplyResult { - session_id: first.session_id, - card_id: first.card.id().into(), - accepted: true, - patch_ids: vec![first_patch.patches[0].id.clone()], - changed_files: vec![PathBuf::from("src/work.ts")], - error: None, - context: editor_context("FIRST\nmiddle\nlast"), - }; - - let second = engine.apply_result(result).await.unwrap(); - - assert!(matches!(second.card, Card::Patch(_))); - assert_eq!(second.turn_token_usage.total_tokens, 0); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - assert_eq!(second.goal.status, loopbiotic_protocol::GoalStatus::Active); - assert_eq!( - engine - .get(&second.session_id) - .unwrap() - .completed_steps - .len(), - 1 - ); - - let Card::Patch(second_patch) = &second.card else { - unreachable!(); - }; - let result = PatchApplyResult { - session_id: second.session_id, - card_id: second.card.id().into(), - accepted: true, - patch_ids: vec![second_patch.patches[0].id.clone()], - changed_files: vec![PathBuf::from("src/work.ts")], - error: None, - context: editor_context("FIRST\nmiddle\nLAST"), - }; - let complete = engine.apply_result(result).await.unwrap(); - - assert!(matches!(complete.card, Card::Summary(_))); - assert_eq!(complete.turn_token_usage.total_tokens, 0); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - assert_eq!( - complete.goal.status, - loopbiotic_protocol::GoalStatus::Complete - ); - assert_eq!(complete.goal.completed_steps.len(), 2); - } - - #[tokio::test] - async fn continuous_goal_reviews_a_multi_file_batch_without_more_model_turns() { - let backend = Arc::new(MultiFileGoalBackend::default()); - let reads = Arc::new(AtomicUsize::new(0)); - let mut engine = Engine::new(backend.clone()); - let observed_reads = reads.clone(); - engine.set_source_context_provider(Arc::new(move |file, _session_id| { - let observed_reads = observed_reads.clone(); - Box::pin(async move { - observed_reads.fetch_add(1, Ordering::SeqCst); - let text = if file == std::path::Path::new("src/work.ts") { - "first" - } else { - "other" - }; - let mut context = editor_context(text); - context.file = file; - Some(context) - }) - })); - let mut goal = params(); - goal.mode = Mode::Auto; - goal.buffer_text = "first".into(); - - let first = engine.start(goal).await.unwrap(); - let Card::Patch(first_patch) = &first.card else { - panic!( - "expected first file hunk, got {:?}; attempts {:?}", - first.card, first.attempts - ); - }; - assert_eq!(first_patch.patches[0].file, PathBuf::from("src/work.ts")); - assert!(!first_patch.goal_complete); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - assert_eq!(reads.load(Ordering::SeqCst), 2); - - let second = engine - .apply_result(PatchApplyResult { - session_id: first.session_id, - card_id: first.card.id().into(), - accepted: true, - patch_ids: vec![first_patch.patches[0].id.clone()], - changed_files: vec![PathBuf::from("src/work.ts")], - error: None, - context: editor_context("FIRST"), - }) - .await - .unwrap(); - let Card::Patch(second_patch) = &second.card else { - panic!("expected second file hunk"); - }; - assert_eq!(second_patch.patches[0].file, PathBuf::from("src/other.ts")); - assert!(second_patch.goal_complete); - assert_eq!(second.turn_token_usage.total_tokens, 0); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - - let mut other_context = editor_context("OTHER"); - other_context.file = PathBuf::from("src/other.ts"); - let complete = engine - .apply_result(PatchApplyResult { - session_id: second.session_id, - card_id: second.card.id().into(), - accepted: true, - patch_ids: vec![second_patch.patches[0].id.clone()], - changed_files: vec![PathBuf::from("src/other.ts")], - error: None, - context: other_context, - }) - .await - .unwrap(); - - assert!(matches!(complete.card, Card::Summary(_))); - assert_eq!( - complete.goal.status, - loopbiotic_protocol::GoalStatus::Complete - ); - assert_eq!(complete.turn_token_usage.total_tokens, 0); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn continuous_goal_reworks_a_rejected_hunk_without_leaving_the_loop() { - let backend = Arc::new(MockBackend); - let mut engine = Engine::new(backend); - let mut goal = params(); - goal.mode = Mode::Auto; - let first = engine.start(goal).await.unwrap(); - let Card::Patch(first_patch) = &first.card else { - panic!("expected patch card"); - }; - let result = PatchApplyResult { - session_id: first.session_id, - card_id: first.card.id().into(), - accepted: false, - patch_ids: vec![first_patch.patches[0].id.clone()], - changed_files: vec![], - error: None, - context: editor_context("placeholder"), - }; - - let reworked = engine.apply_result(result).await.unwrap(); - - assert!(matches!(reworked.card, Card::Patch(_))); - assert!(reworked.turn_token_usage.total_tokens > 0); - assert!(reworked.goal.completed_steps.is_empty()); - assert_eq!( - reworked.goal.status, - loopbiotic_protocol::GoalStatus::Active - ); - } - - #[tokio::test] - async fn why_explains_and_restores_the_same_pending_hunk() { - let backend = Arc::new(MockBackend); - let mut engine = Engine::new(backend); - let mut goal = params(); - goal.mode = Mode::Auto; - let first = engine.start(goal).await.unwrap(); - let patch_id = first.card.id().to_string(); - - let explained = engine.action(&first.session_id, Action::Why).await.unwrap(); - - let Card::Finding(explanation) = explained.card else { - panic!("expected patch explanation"); - }; - assert!(explanation.actions.contains(&Action::ResumeDraft)); - assert_eq!( - engine.get(&first.session_id).unwrap().state, - SessionState::PatchExplained - ); - - let resumed = engine - .action(&first.session_id, Action::ResumeDraft) - .await - .unwrap(); - - assert!(matches!(resumed.card, Card::Patch(_))); - assert_eq!(resumed.card.id(), patch_id); - assert_eq!(resumed.turn_token_usage, TokenUsage::default()); - assert_eq!( - engine.get(&first.session_id).unwrap().state, - SessionState::PatchShown - ); - } - - #[tokio::test] - async fn rejects_apply_result_for_another_patch_card() { - let backend = Arc::new(MockBackend); - let mut engine = Engine::new(backend); - let start = engine.start(params()).await.unwrap(); - let patch = engine.action(&start.session_id, Action::Fix).await.unwrap(); - let result = PatchApplyResult { - session_id: start.session_id.clone(), - card_id: "c_stale".into(), - accepted: true, - patch_ids: vec!["p_1".into()], - changed_files: vec![PathBuf::from("src/work.ts")], - error: None, - context: editor_context("payload = payload or {}"), - }; - - let error = engine.apply_result(result).await.unwrap_err(); - - assert!(error.to_string().contains("current patch card")); - assert_eq!( - engine.get(&start.session_id).unwrap().state, - SessionState::PatchShown - ); - assert_eq!(patch.card.id(), "c_patch"); - } - - #[tokio::test] - async fn rejected_apply_returns_reworked_patch_without_summary() { - let backend = Arc::new(MockBackend); - let mut engine = Engine::new(backend); - let start = engine.start(params()).await.unwrap(); - let patch = engine.action(&start.session_id, Action::Fix).await.unwrap(); - let result = PatchApplyResult { - session_id: start.session_id.clone(), - card_id: patch.card.id().into(), - accepted: false, - patch_ids: vec!["p_1".into()], - changed_files: vec![], - error: Some("patch context is ambiguous".into()), - context: editor_context("placeholder"), - }; - - let reworked = engine.apply_result(result).await.unwrap(); - - assert!(matches!(reworked.card, Card::Patch(_))); - assert_eq!( - engine.get(&start.session_id).unwrap().state, - SessionState::PatchShown - ); - } - - #[tokio::test] - async fn rejects_apply_result_with_unreported_target_file() { - let backend = Arc::new(MockBackend); - let mut engine = Engine::new(backend); - let start = engine.start(params()).await.unwrap(); - let patch = engine.action(&start.session_id, Action::Fix).await.unwrap(); - let result = PatchApplyResult { - session_id: start.session_id.clone(), - card_id: patch.card.id().into(), - accepted: true, - patch_ids: vec!["p_1".into()], - changed_files: vec![PathBuf::from("src/other.ts")], - error: None, - context: editor_context("payload = payload or {}"), - }; - - let error = engine.apply_result(result).await.unwrap_err(); - - assert!(error.to_string().contains("changed files")); - assert!( - engine - .get(&start.session_id) - .unwrap() - .accepted_patches - .is_empty() - ); - } - - #[tokio::test] - async fn returns_typed_card_and_keeps_session_after_backend_error() { - let backend = Arc::new(FlakyBackend::default()); - let mut engine = Engine::new(backend); - let start = engine.start(params()).await.unwrap(); - let failed = engine - .action(&start.session_id, Action::Follow) - .await - .unwrap(); - - let Card::Error(error) = failed.card else { - panic!("expected error card"); - }; - assert!(error.message.contains("backend failed")); - - let next = engine.action(&start.session_id, Action::Why).await.unwrap(); - - assert!(matches!(next.card, Card::Finding(_))); - } - - #[tokio::test] - async fn converts_bad_patch_to_error_card() { - let backend = Arc::new(BadPatchBackend); - let mut engine = Engine::new(backend); - let start = engine.start(params()).await.unwrap(); - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - - let Card::Error(card) = result.card else { - panic!("expected error card"); - }; - - assert!(card.message.contains("diff has no hunks")); - - let retry = engine - .action(&start.session_id, Action::Retry) - .await - .unwrap(); - - assert!(matches!(retry.card, Card::Error(_))); - } - - #[tokio::test] - async fn repairs_invalid_patch_before_showing_it_to_user() { - let backend = Arc::new(RepairingPatchBackend::default()); - let mut engine = Engine::new(backend); - let start = engine.start(params()).await.unwrap(); - - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - - let Card::Patch(card) = result.card else { - panic!("expected repaired patch card"); - }; - assert_eq!( - card.patches[0].diff, - "@@ -1,1 +1,1 @@\n-placeholder\n+repaired\n" - ); - assert_eq!(result.attempts.len(), 2); - assert_eq!(result.attempts[0].outcome, "contract_retry"); - assert!( - result.attempts[0] - .detail - .as_deref() - .unwrap() - .contains("patch context was not found") - ); - assert!(result.attempts[0].candidate_card.is_some()); - assert_eq!(result.attempts[0].token_usage.total_tokens, 15); - assert_eq!(result.attempts[1].outcome, "accepted"); - assert_eq!(result.turn_token_usage.total_tokens, 30); - } - - #[tokio::test] - async fn preserves_wrong_card_type_and_raw_backend_output_for_fix() { - let backend = Arc::new(WrongTypeBackend); - let mut engine = Engine::new(backend); - let start = engine.start(params()).await.unwrap(); - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - - let Card::Error(card) = result.card else { - panic!("expected error card"); - }; - - assert!(card.message.contains("expected patch card")); - assert!(card.message.contains("Received card kind: Finding")); - assert!(card.message.contains("raw finding from backend")); - - let retry = engine - .action(&start.session_id, Action::Retry) - .await - .unwrap(); - assert!(matches!(retry.card, Card::Error(_))); - } - - #[tokio::test] - async fn start_in_fix_mode_requires_a_patch_card() { - let backend = Arc::new(WrongTypeBackend); - let mut engine = Engine::new(backend); - let mut fix_params = params(); - fix_params.mode = Mode::Fix; - - let result = engine.start(fix_params).await.unwrap(); - let Card::Error(card) = result.card else { - panic!("expected error card"); - }; - - assert!(card.message.contains("expected the next goal patch")); - assert_eq!( - engine.get(&result.session_id).unwrap().state, - SessionState::GoalLoopFailed - ); - let apply_error = engine - .action(&result.session_id, Action::Apply) - .await - .unwrap_err(); - assert!(apply_error.to_string().contains("invalid action")); - } - - #[tokio::test] - async fn start_returns_typed_card_when_backend_fails() { - let backend = Arc::new(AlwaysFailBackend); - let mut engine = Engine::new(backend); - - let result = engine.start(params()).await.unwrap(); - let Card::Error(card) = result.card else { - panic!("expected error card"); - }; - - assert!(card.message.contains("backend unavailable")); - assert_eq!(result.turn_token_usage, Default::default()); - assert_eq!(result.attempts[0].outcome, "backend_error"); - assert!( - result.attempts[0] - .detail - .as_deref() - .unwrap() - .contains("token limit reached") - ); - assert!(engine.get(&result.session_id).is_some()); - } - - #[tokio::test] - async fn retains_duplicate_observations_without_showing_them_again() { - let backend = Arc::new(RepeatingObservationBackend::default()); - let mut engine = Engine::new(backend); - let start = engine.start(params()).await.unwrap(); - - let result = engine - .action(&start.session_id, Action::Follow) - .await - .unwrap(); - - let Card::Finding(card) = result.card else { - panic!("expected distinct finding after automatic contract retry"); - }; - assert_eq!(card.finding, "The caller still consumes the old shape."); - assert_eq!(card.annotation, None); - assert_eq!(result.attempts.len(), 2); - assert_eq!(result.attempts[0].outcome, "duplicate_retry"); - assert!(result.attempts[0].candidate_card.is_some()); - assert_eq!(result.attempts[1].outcome, "accepted"); - - let observations = &engine.get(&start.session_id).unwrap().known_observations; - assert_eq!(observations.len(), 3); - assert_eq!(observations[0].occurrences, 2); - assert_eq!(observations[1].occurrences, 2); - assert!(!observations[0].active); - assert!(observations[1].active); - assert!(observations[2].active); - } - - #[test] - fn rejects_card_with_invalid_location_coordinates() { - let card = Card::Finding(FindingCard { - id: "c_bad_location".into(), - title: "Target".into(), - finding: "The target is here.".into(), - location: Some(loopbiotic_protocol::Location { - file: "src/main.rs".into(), - line: 0, - column: 1, - }), - annotation: None, - actions: vec![Action::Open, Action::Stop], - }); - - let error = validate_one_card(&card).unwrap_err(); - - assert!(error.to_string().contains("must start at 1")); - } - - #[test] - fn rejects_card_with_empty_semantic_body() { - let card = Card::Finding(FindingCard { - id: "c_empty".into(), - title: "Target".into(), - finding: " ".into(), - location: None, - annotation: None, - actions: vec![Action::Stop], - }); - - let error = validate_one_card(&card).unwrap_err(); - - assert!(error.to_string().contains("finding is empty")); - } - - #[tokio::test] - async fn replies_inside_session() { - let backend = Arc::new(MockBackend); - let mut engine = Engine::new(backend); - let start = engine.start(params()).await.unwrap(); - let result = engine - .reply(&start.session_id, "that is not it".into()) - .await - .unwrap(); - - let Card::Finding(card) = result.card else { - panic!("expected finding card"); - }; - - assert!(card.finding.contains("that is not it")); - } - - #[tokio::test] - async fn action_uses_refreshed_editor_context() { - let backend = Arc::new(MockBackend); - let mut engine = Engine::new(backend); - let start = engine.start(params()).await.unwrap(); - let context = ContextBundle { - cwd: PathBuf::from("/tmp/project"), - file: PathBuf::from("templates/layout.html"), - cursor: Cursor { - line: 12, - column: 1, - }, - selection: None, - buffer_text: "{{ block.preview_html|safe }}".into(), - buffer_start_line: 1, - diagnostics: vec![], - hints: vec![], - artifacts: vec![], - report: None, - }; - - engine.update_context(&start.session_id, context).unwrap(); - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - let Card::Patch(card) = result.card else { - panic!("expected patch card"); - }; - - assert_eq!(card.patches[0].file, PathBuf::from("templates/layout.html")); - } - - #[derive(Default)] - struct FlakyBackend { - failed: AtomicBool, - } - - #[derive(Default)] - struct BatchGoalBackend { - calls: AtomicUsize, - } - - #[async_trait] - impl BackendAdapter for BatchGoalBackend { - async fn next_card(&self, req: BackendRequest) -> Result { - self.calls.fetch_add(1, Ordering::SeqCst); - assert!(req.card_contract.allow_goal_completion); - assert_eq!(req.card_contract.max_patch_files, MAX_GOAL_PATCH_FILES); - assert_eq!( - req.card_contract.max_hunks_per_patch, - MAX_GOAL_HUNKS_PER_PATCH - ); - assert_eq!(req.card_contract.max_changed_lines, MAX_GOAL_CHANGED_LINES); - - Ok(BackendResponse { - card: Card::Patch(PatchCard { - id: "c_batch".into(), - title: "Complete local change".into(), - explanation: "Prepare both independent edits.".into(), - warnings: vec![], - goal_complete: true, - patches: vec![FilePatch { - id: "p_batch".into(), - file: "src/work.ts".into(), - diff: "@@ -1,2 +1,2 @@\n-first\n+FIRST\n middle\n@@ -2,2 +2,2 @@\n middle\n-last\n+LAST\n".into(), - explanation: "Updates both required locations.".into(), - }], - actions: vec![Action::Apply, Action::Why, Action::Retry, Action::Stop], - }), - raw_output: None, - metadata: BackendMetadata { - backend: "batch_goal".into(), - model: None, - token_usage: Some(TokenUsage::estimated(100, 20)), - activities: vec![], - attempts: vec![], - }, - }) - } - - fn capabilities(&self) -> BackendInfo { - MockBackend::info() - } - } - - #[derive(Default)] - struct MultiFileGoalBackend { - calls: AtomicUsize, - } - - #[async_trait] - impl BackendAdapter for MultiFileGoalBackend { - async fn next_card(&self, req: BackendRequest) -> Result { - self.calls.fetch_add(1, Ordering::SeqCst); - assert_eq!(req.card_contract.max_patch_files, MAX_GOAL_PATCH_FILES); - - Ok(BackendResponse { - card: Card::Patch(PatchCard { - id: "c_multi".into(), - title: "Complete workspace change".into(), - explanation: "Update both required files.".into(), - warnings: vec![], - goal_complete: true, - patches: vec![ - FilePatch { - id: "p_work".into(), - file: "src/work.ts".into(), - diff: "@@ -1,1 +1,1 @@\n-first\n+FIRST\n".into(), - explanation: "Update the producer.".into(), - }, - FilePatch { - id: "p_other".into(), - file: "src/other.ts".into(), - diff: "@@ -1,1 +1,1 @@\n-other\n+OTHER\n".into(), - explanation: "Update the consumer.".into(), - }, - ], - actions: vec![Action::Apply, Action::Why, Action::Retry, Action::Stop], - }), - raw_output: None, - metadata: BackendMetadata { - backend: "multi_file_goal".into(), - model: None, - token_usage: Some(TokenUsage::estimated(100, 20)), - activities: vec![], - attempts: vec![], - }, - }) - } - - fn capabilities(&self) -> BackendInfo { - MockBackend::info() - } - } - - struct BadPatchBackend; - - #[derive(Default)] - struct RepairingPatchBackend { - failed_once: AtomicBool, - } - - struct WrongTypeBackend; - - struct AlwaysFailBackend; - - #[derive(Default)] - struct RepeatingObservationBackend { - calls: std::sync::atomic::AtomicUsize, - } - - #[async_trait] - impl BackendAdapter for RepeatingObservationBackend { - async fn next_card(&self, req: BackendRequest) -> Result { - let call = self.calls.fetch_add(1, Ordering::SeqCst); - let card = match (call, req.action) { - (0, BackendAction::Start) | (1, BackendAction::User(Action::Follow)) => { - Card::Hypothesis(HypothesisCard { - id: format!("c_repeat_{call}"), - title: "Repeated branch".into(), - claim: "The branch returns before building the preview.".into(), - evidence: Some(loopbiotic_protocol::LocationEvidence { - file: "src/work.ts".into(), - line: 1, - column: 1, - annotation: "The preview is skipped here.".into(), - }), - next_move: None, - actions: vec![Action::Follow, Action::Fix, Action::Stop], - }) - } - (2, BackendAction::ContractRetry(_)) => Card::Finding(FindingCard { - id: "c_distinct".into(), - title: "Consumer remains".into(), - finding: "The caller still consumes the old shape.".into(), - location: Some(loopbiotic_protocol::Location { - file: "src/work.ts".into(), - line: 1, - column: 1, - }), - annotation: Some("The preview is skipped here.".into()), - actions: vec![Action::Fix, Action::Stop], - }), - _ => panic!("unexpected observation backend request"), - }; - - Ok(BackendResponse { - card, - raw_output: None, - metadata: BackendMetadata { - backend: "repeating_observation".into(), - model: None, - token_usage: Some(loopbiotic_protocol::TokenUsage::estimated(10, 5)), - activities: vec![], - attempts: vec![], - }, - }) - } - - fn capabilities(&self) -> BackendInfo { - BackendInfo { - name: "repeating_observation".into(), - streaming: false, - patches: true, - reasoning: false, - can_read_project: false, - can_use_tools: false, - } - } - } - - #[async_trait] - impl BackendAdapter for AlwaysFailBackend { - async fn next_card(&self, _req: BackendRequest) -> Result { - Err(anyhow!("backend unavailable: token limit reached")) - } - - fn capabilities(&self) -> BackendInfo { - BackendInfo { - name: "always_fail".into(), - streaming: false, - patches: false, - reasoning: false, - can_read_project: false, - can_use_tools: false, - } - } - } - - #[async_trait] - impl BackendAdapter for FlakyBackend { - async fn next_card(&self, req: BackendRequest) -> Result { - if matches!(req.action, BackendAction::User(Action::Follow)) - && !self.failed.swap(true, Ordering::SeqCst) - { - return Err(anyhow!("backend failed")); - } - - let card = match req.action { - BackendAction::Start => Card::Hypothesis(HypothesisCard { - id: "c_1".into(), - title: "Start".into(), - claim: "Initial claim.".into(), - evidence: None, - next_move: None, - actions: vec![Action::Follow, Action::Why, Action::Stop], - }), - _ => Card::Finding(FindingCard { - id: "c_2".into(), - title: "Recovered".into(), - finding: "Session still works.".into(), - location: None, - annotation: None, - actions: vec![Action::Stop], - }), - }; - - Ok(BackendResponse { - card, - raw_output: None, - metadata: BackendMetadata { - backend: "flaky".into(), - model: None, - token_usage: None, - activities: vec![], - attempts: vec![], - }, - }) - } - - fn capabilities(&self) -> BackendInfo { - BackendInfo { - name: "flaky".into(), - streaming: false, - patches: false, - reasoning: false, - can_read_project: false, - can_use_tools: false, - } - } - } - - #[async_trait] - impl BackendAdapter for BadPatchBackend { - async fn next_card(&self, req: BackendRequest) -> Result { - let card = match req.action { - BackendAction::Start => Card::Hypothesis(HypothesisCard { - id: "c_1".into(), - title: "Start".into(), - claim: "Initial claim.".into(), - evidence: None, - next_move: None, - actions: vec![Action::Fix, Action::Stop], - }), - _ => Card::Patch(PatchCard { - id: "c_patch".into(), - title: "Bad patch".into(), - explanation: "Invalid patch.".into(), - warnings: vec![], - goal_complete: false, - patches: vec![FilePatch { - id: "p_1".into(), - file: "src/work.ts".into(), - diff: "not a unified diff".into(), - explanation: "Broken.".into(), - }], - actions: vec![Action::Apply, Action::Retry, Action::Stop], - }), - }; - - Ok(BackendResponse { - card, - raw_output: None, - metadata: BackendMetadata { - backend: "bad_patch".into(), - model: None, - token_usage: None, - activities: vec![], - attempts: vec![], - }, - }) - } - - fn capabilities(&self) -> BackendInfo { - BackendInfo { - name: "bad_patch".into(), - streaming: false, - patches: true, - reasoning: false, - can_read_project: false, - can_use_tools: false, - } - } - } - - #[async_trait] - impl BackendAdapter for RepairingPatchBackend { - async fn next_card(&self, req: BackendRequest) -> Result { - let card = match req.action { - BackendAction::Start => Card::Hypothesis(HypothesisCard { - id: "c_1".into(), - title: "Start".into(), - claim: "The local representation needs one change.".into(), - evidence: None, - next_move: None, - actions: vec![Action::Fix, Action::Stop], - }), - BackendAction::User(Action::Fix) - if !self.failed_once.swap(true, Ordering::SeqCst) => - { - Card::Patch(PatchCard { - id: "c_invalid".into(), - title: "Invalid first attempt".into(), - explanation: "This attempt has stale context.".into(), - warnings: vec![], - goal_complete: false, - patches: vec![FilePatch { - id: "p_1".into(), - file: "src/work.ts".into(), - diff: "@@ -1,1 +1,1 @@\n-stale\n+new\n".into(), - explanation: "Stale attempt.".into(), - }], - actions: vec![Action::Apply, Action::Retry, Action::Stop], - }) - } - BackendAction::ContractRetry(reason) => { - assert!(reason.contains("patch context was not found")); - Card::Patch(PatchCard { - id: "c_repaired".into(), - title: "Repaired local step".into(), - explanation: "Use exact current context.".into(), - warnings: vec![], - goal_complete: false, - patches: vec![FilePatch { - id: "p_1".into(), - file: "src/work.ts".into(), - diff: "@@ -1,1 +1,1 @@\n-placeholder\n+repaired\n".into(), - explanation: "Repair one line.".into(), - }], - actions: vec![Action::Apply, Action::Retry, Action::Stop], - }) - } - _ => panic!("unexpected repair backend request"), - }; - - Ok(BackendResponse { - card, - raw_output: None, - metadata: BackendMetadata { - backend: "repairing_patch".into(), - model: None, - token_usage: Some(loopbiotic_protocol::TokenUsage::estimated(10, 5)), - activities: vec![], - attempts: vec![], - }, - }) - } - - fn capabilities(&self) -> BackendInfo { - BackendInfo { - name: "repairing_patch".into(), - streaming: false, - patches: true, - reasoning: false, - can_read_project: false, - can_use_tools: false, - } - } - } - - #[async_trait] - impl BackendAdapter for WrongTypeBackend { - async fn next_card(&self, req: BackendRequest) -> Result { - let card = match req.action { - BackendAction::Start => Card::Hypothesis(HypothesisCard { - id: "c_1".into(), - title: "Start".into(), - claim: "Wrong type for this test.".into(), - evidence: None, - next_move: None, - actions: vec![Action::Fix, Action::Stop], - }), - _ => Card::Finding(FindingCard { - id: "c_finding".into(), - title: "Wrong type".into(), - finding: "This is deliberately not a patch.".into(), - location: None, - annotation: None, - actions: vec![Action::Fix, Action::Stop], - }), - }; - - Ok(BackendResponse { - card, - raw_output: Some("raw finding from backend".into()), - metadata: BackendMetadata { - backend: "wrong_type".into(), - model: None, - token_usage: None, - activities: vec![], - attempts: vec![], - }, - }) - } - - fn capabilities(&self) -> BackendInfo { - BackendInfo { - name: "wrong_type".into(), - streaming: false, - patches: false, - reasoning: false, - can_read_project: false, - can_use_tools: false, - } - } - } - - #[derive(Default)] - struct CountingBackend { - inner: MockBackend, - calls: std::sync::Mutex>, - } - - impl CountingBackend { - fn record(&self, path: &str, action: &BackendAction) { - self.calls - .lock() - .unwrap() - .push(format!("{path}:{action:?}")); - } - } - - #[async_trait] - impl BackendAdapter for CountingBackend { - async fn next_card(&self, req: BackendRequest) -> Result { - self.record("plain", &req.action); - self.inner.next_card(req).await - } - - async fn next_card_with_progress( - &self, - req: BackendRequest, - _progress: Option, - ) -> Result { - self.record("progress", &req.action); - self.inner.next_card(req).await - } - - fn capabilities(&self) -> BackendInfo { - self.inner.capabilities() - } - } - - #[tokio::test] - async fn prefetched_fix_is_consumed_without_a_second_backend_call() { - let backend = Arc::new(CountingBackend::default()); - let mut engine = Engine::new(backend.clone()); - engine.set_prefetch_mode(PrefetchMode::Fix); - - let start = engine.start(params()).await.unwrap(); - assert!(matches!(start.card, Card::Hypothesis(_))); - - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - assert!(matches!(result.card, Card::Patch(_))); - - let calls = backend.calls.lock().unwrap().clone(); - assert_eq!(calls.len(), 2, "unexpected backend calls: {calls:?}"); - assert!(calls[0].starts_with("progress:Start")); - assert!(calls[1].starts_with("plain:User(Fix)")); - } - - #[tokio::test] - async fn stale_prefetch_is_discarded_after_context_change() { - let backend = Arc::new(CountingBackend::default()); - let mut engine = Engine::new(backend.clone()); - engine.set_prefetch_mode(PrefetchMode::Fix); - - let start = engine.start(params()).await.unwrap(); - let context = ContextBundle { - cwd: PathBuf::from("/tmp/project"), - file: PathBuf::from("src/work.ts"), - cursor: Cursor { line: 1, column: 1 }, - selection: None, - buffer_text: "const edited = true".into(), - buffer_start_line: 1, - diagnostics: vec![], - hints: vec![], - artifacts: vec![], - report: None, - }; - engine.update_context(&start.session_id, context).unwrap(); - - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - let Card::Patch(card) = result.card else { - panic!("expected patch card"); - }; - - // The mock builds the diff from the buffer's first line, so a patch - // produced from the fresh request must reference the edited buffer. - assert!( - card.patches[0].diff.contains("const edited = true"), - "patch was built from stale context: {}", - card.patches[0].diff - ); - let calls = backend.calls.lock().unwrap().clone(); - assert!( - calls - .iter() - .any(|call| call.starts_with("progress:User(Fix)")), - "real fix call missing: {calls:?}" - ); - } - - #[tokio::test] - async fn fingerprint_ignores_optimizer_telemetry() { - let backend = Arc::new(CountingBackend::default()); - let mut engine = Engine::new(backend); - engine.set_prefetch_mode(PrefetchMode::Fix); - let start = engine.start(params()).await.unwrap(); - let session = engine.get(&start.session_id).unwrap(); - - let request = engine.request( - session, - BackendAction::User(Action::Fix), - session.context.clone(), - &NextState::Patch, - ); - let mut noisy = request.clone(); - noisy.context.report = Some(loopbiotic_protocol::ContextReport { - enabled: true, - cache_hits: 42, - ..Default::default() - }); - - assert_eq!(request_fingerprint(&request), request_fingerprint(&noisy)); - } - - #[derive(Default)] - struct ExpectationRecorder { - inner: MockBackend, - requests: std::sync::Mutex, String)>>, - } - - #[async_trait] - impl BackendAdapter for ExpectationRecorder { - async fn next_card(&self, req: BackendRequest) -> Result { - self.requests - .lock() - .unwrap() - .push((req.card_contract.expected_kind, req.session.prompt.clone())); - self.inner.next_card(req).await - } - - fn capabilities(&self) -> BackendInfo { - self.inner.capabilities() - } - } - - #[tokio::test] - async fn plain_auto_prompt_expects_any_kind() { - let backend = Arc::new(ExpectationRecorder::default()); - let mut engine = Engine::new(backend.clone()); - - let mut auto = params(); - auto.mode = Mode::Auto; - engine.start(auto).await.unwrap(); - - let requests = backend.requests.lock().unwrap().clone(); - assert_eq!(requests[0].0, None); - assert_eq!(requests[0].1, "payload is empty"); - } - - #[tokio::test] - async fn kind_prefix_forces_the_expected_card() { - let backend = Arc::new(ExpectationRecorder::default()); - let mut engine = Engine::new(backend.clone()); - let mut start_params = params(); - start_params.prompt = "/patch guard the payload".into(); - - engine.start(start_params).await.unwrap(); - - let requests = backend.requests.lock().unwrap().clone(); - assert_eq!(requests[0].0, Some(CardKind::Patch)); - assert_eq!(requests[0].1, "guard the payload"); - } - - #[derive(Default)] - struct NavigatingBackend { - calls: std::sync::Mutex>, - } - - #[async_trait] - impl BackendAdapter for NavigatingBackend { - async fn next_card(&self, req: BackendRequest) -> Result { - self.calls.lock().unwrap().push(format!("{:?}", req.action)); - - let card = match req.action { - BackendAction::Start => Card::Hypothesis(HypothesisCard { - id: "c_1".into(), - title: "Wrong buffer suspected".into(), - claim: "The sizing lives in the component file.".into(), - evidence: None, - next_move: None, - actions: vec![Action::Fix, Action::Stop], - }), - BackendAction::User(Action::Fix) => { - Card::OpenLocation(loopbiotic_protocol::OpenLocationCard { - id: "c_nav".into(), - reason: "The change belongs in the component file.".into(), - location: loopbiotic_protocol::Location { - file: "src/component.ts".into(), - line: 3, - column: 1, - }, - }) - } - BackendAction::LocationGranted => { - let old_line = req.context.buffer_text.lines().next().unwrap_or_default(); - Card::Patch(PatchCard { - id: "c_patch".into(), - title: "Adjust icon size".into(), - explanation: "Sets the icon size on the component.".into(), - warnings: vec![], - goal_complete: false, - patches: vec![FilePatch { - id: "p_1".into(), - file: req.context.file.clone(), - diff: format!("@@ -1,1 +1,1 @@\n-{old_line}\n+const size = 16\n"), - explanation: "Shrinks the icon.".into(), - }], - actions: vec![ - Action::Apply, - Action::Retry, - Action::EditPrompt, - Action::Stop, - ], - }) - } - other => panic!("unexpected action {other:?}"), - }; - - Ok(BackendResponse { - card, - raw_output: None, - metadata: BackendMetadata { - backend: "navigating".into(), - model: None, - token_usage: Some(loopbiotic_protocol::TokenUsage::estimated(10, 5)), - activities: vec![], - attempts: vec![], - }, - }) - } - - fn capabilities(&self) -> BackendInfo { - BackendInfo { - name: "navigating".into(), - streaming: false, - patches: true, - reasoning: false, - can_read_project: false, - can_use_tools: false, - } - } - } - - fn granted_context() -> ContextBundle { - ContextBundle { - cwd: PathBuf::from("/tmp/project"), - file: PathBuf::from("src/component.ts"), - cursor: Cursor { line: 3, column: 1 }, - selection: None, - buffer_text: "const size = 24".into(), - buffer_start_line: 1, - diagnostics: vec![], - hints: vec![], - artifacts: vec![], - report: None, - } - } - - #[tokio::test] - async fn open_location_grant_continues_the_same_turn() { - let backend = Arc::new(NavigatingBackend::default()); - let granted: Arc>> = Arc::default(); - let granted_log = granted.clone(); - let mut engine = Engine::new(backend.clone()); - engine.set_location_granter(Arc::new(move |request, _session| { - granted_log - .lock() - .unwrap() - .push(request.location.file.display().to_string()); - Box::pin(async move { Some(granted_context()) }) - })); - - let start = engine.start(params()).await.unwrap(); - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - - let Card::Patch(card) = result.card else { - panic!("expected patch card, got {:?}", result.card); - }; - assert_eq!(card.patches[0].file, PathBuf::from("src/component.ts")); - assert!(card.patches[0].diff.contains("const size = 24")); - assert_eq!(granted.lock().unwrap().as_slice(), ["src/component.ts"]); - assert_eq!( - engine.get(&start.session_id).unwrap().context.file, - PathBuf::from("src/component.ts") - ); - - let calls = backend.calls.lock().unwrap().clone(); - assert!(calls.iter().any(|call| call.contains("LocationGranted"))); - } - - #[tokio::test] - async fn declined_open_location_surfaces_a_deny_card() { - let backend = Arc::new(NavigatingBackend::default()); - let mut engine = Engine::new(backend); - engine.set_location_granter(Arc::new(|_, _| Box::pin(async { None }))); - - let start = engine.start(params()).await.unwrap(); - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - - let Card::Deny(card) = result.card else { - panic!("expected deny card, got {:?}", result.card); - }; - assert_eq!( - card.location.as_ref().map(|l| l.file.clone()), - Some(PathBuf::from("src/component.ts")) - ); - } - - #[tokio::test] - async fn open_location_without_granter_becomes_a_deny_card() { - let backend = Arc::new(NavigatingBackend::default()); - let mut engine = Engine::new(backend); - - let start = engine.start(params()).await.unwrap(); - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - - assert!(matches!(result.card, Card::Deny(_))); - } -} diff --git a/rust/crates/loopbiotic_harness/src/engine/goal.rs b/rust/crates/loopbiotic_harness/src/engine/goal.rs new file mode 100644 index 0000000..28b7359 --- /dev/null +++ b/rust/crates/loopbiotic_harness/src/engine/goal.rs @@ -0,0 +1,198 @@ +//! Continuous-goal bookkeeping: splitting goal batches into reviewable hunk +//! cards, tracking goal progress/state, and assembling completion summaries. + +use std::collections::VecDeque; + +use anyhow::{Result, anyhow}; +use loopbiotic_protocol::{ + Action, ActionResult, Card, ErrorCard, GoalProgress, SummaryCard, TokenUsage, +}; + +use crate::session::Session; +use crate::state::{NextState, SessionState}; + +use super::turn::normalize_step; + +pub(super) fn goal_progress(session: &Session) -> GoalProgress { + GoalProgress { + statement: session.original_prompt.clone(), + completed_steps: session.completed_steps.clone(), + known_observations: session.known_observations.clone(), + status: session.goal_status, + next_step: session.next_step.clone(), + } +} + +pub(super) fn complete_goal_locally(session_id: &str, session: &mut Session) -> ActionResult { + let mut changed_files = session + .completed_step_signatures + .iter() + .map(|(file, _)| file.clone()) + .collect::>(); + changed_files.sort(); + changed_files.dedup(); + + session.state = SessionState::Summary; + session.goal_status = loopbiotic_protocol::GoalStatus::Complete; + session.next_step = None; + let card = Card::Summary(SummaryCard { + id: session.next_card_id("complete"), + title: "Goal complete".into(), + summary: format!( + "Completed {} reviewed change{} for: {}", + session.completed_steps.len(), + if session.completed_steps.len() == 1 { + "" + } else { + "s" + }, + session.original_prompt + ), + changed_files, + next_actions: vec![Action::RunCheck, Action::Stop], + }); + session.cards.push(card.clone()); + + ActionResult { + session_id: session_id.into(), + card, + goal: goal_progress(session), + token_usage: session.token_usage.clone(), + turn_token_usage: TokenUsage::default(), + context_report: session.context.report.clone(), + model: None, + attempts: vec![], + } +} + +pub(super) fn update_goal_state(session: &mut Session, card: &Card, next_state: &NextState) { + if !matches!(next_state, NextState::GoalLoop) { + return; + } + + match card { + Card::Patch(card) => { + session.goal_status = loopbiotic_protocol::GoalStatus::Active; + session.next_step = Some(card.explanation.clone()); + } + Card::Summary(_) => { + session.goal_status = loopbiotic_protocol::GoalStatus::Complete; + session.next_step = None; + } + Card::Finding(card) => { + session.goal_status = loopbiotic_protocol::GoalStatus::Active; + session.next_step = Some(card.finding.clone()); + } + Card::Hypothesis(card) => { + session.goal_status = loopbiotic_protocol::GoalStatus::Active; + session.next_step = Some(card.claim.clone()); + } + Card::Choice(_) => { + session.goal_status = loopbiotic_protocol::GoalStatus::Active; + session.next_step = None; + } + _ => {} + } +} + +pub(super) fn completed_patch_steps(session: &Session) -> Vec { + let Some(Card::Patch(card)) = session.cards.last() else { + return vec![]; + }; + + card.patches + .iter() + .map(|patch| format!("{}: {}", patch.file.display(), patch.explanation)) + .collect() +} + +pub(super) fn queue_goal_patch_cards(session: &mut Session, card: Card) -> Result { + let Card::Patch(card) = card else { + session.pending_patch_cards.clear(); + return Ok(card); + }; + + let mut cards = Vec::new(); + for patch in card.patches { + let diff = loopbiotic_patch::UnifiedDiff::parse(&patch.diff)?; + let hunk_count = diff.hunks.len(); + for (index, hunk) in diff.hunks.into_iter().enumerate() { + let suffix = if hunk_count == 1 { + String::new() + } else { + format!(" ({}/{hunk_count})", index + 1) + }; + let explanation = if hunk_count == 1 { + patch.explanation.clone() + } else { + format!("{} Hunk {}/{}.", patch.explanation, index + 1, hunk_count) + }; + cards.push(loopbiotic_protocol::PatchCard { + id: format!("{}_h{}", card.id, cards.len() + 1), + title: format!("{}{}", card.title, suffix), + explanation: explanation.clone(), + warnings: card.warnings.clone(), + goal_complete: false, + patches: vec![loopbiotic_protocol::FilePatch { + id: format!("{}_h{}", patch.id, index + 1), + file: patch.file.clone(), + diff: loopbiotic_patch::UnifiedDiff { hunks: vec![hunk] }.render(), + explanation, + }], + actions: card.actions.clone(), + }); + } + } + + let mut cards = cards.into_iter(); + let mut first = cards + .next() + .ok_or_else(|| anyhow!("goal patch contains no reviewable hunks"))?; + let mut pending = cards.collect::>(); + if card.goal_complete { + if let Some(last) = pending.back_mut() { + last.goal_complete = true; + } else { + first.goal_complete = true; + } + } + session.pending_patch_cards = pending; + + Ok(Card::Patch(first)) +} + +pub(super) fn completed_patch_signatures(session: &Session) -> Vec<(std::path::PathBuf, String)> { + let Some(Card::Patch(card)) = session.cards.last() else { + return vec![]; + }; + + card.patches + .iter() + .map(|patch| (patch.file.clone(), normalize_step(&patch.explanation))) + .collect() +} + +pub(super) fn rejected_card( + session: &Session, + received: &Card, + error: anyhow::Error, + raw_output: Option<&str>, +) -> Card { + let mut message = format!("{error}\nReceived card kind: {:?}.", received.kind()); + + if let Some(raw_output) = raw_output + .map(str::trim) + .filter(|output| !output.is_empty()) + { + let raw_output = raw_output.chars().take(1_200).collect::(); + message.push_str("\n\nRaw backend response:\n"); + message.push_str(&raw_output); + } + + Card::Error(ErrorCard { + id: session.next_card_id("rejected"), + title: "Backend card rejected".into(), + message, + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }) +} diff --git a/rust/crates/loopbiotic_harness/src/engine/mod.rs b/rust/crates/loopbiotic_harness/src/engine/mod.rs new file mode 100644 index 0000000..3b8aec9 --- /dev/null +++ b/rust/crates/loopbiotic_harness/src/engine/mod.rs @@ -0,0 +1,763 @@ +mod goal; +mod observations; +mod prefetch; +mod turn; +mod validate; + +#[cfg(test)] +mod tests; + +pub use prefetch::PrefetchMode; + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{Result, anyhow}; +use loopbiotic_backends::{ + BackendAction, BackendAdapter, BackendRequest, CardContract, ProgressReporter, SessionSnapshot, +}; +use loopbiotic_context::ContextOptimizer; +use loopbiotic_patch::PatchCoherence; +use loopbiotic_protocol::{ + Action, ActionResult, Card, CardKind, ContextBundle, ContextPolicy, MAX_GOAL_CHANGED_LINES, + MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, Mode, PatchApplyResult, StartSessionParams, + StartSessionResult, SummaryCard, TokenUsage, +}; + +use crate::session::Session; +use crate::state::{NextState, SessionState}; + +use goal::{ + complete_goal_locally, completed_patch_signatures, completed_patch_steps, goal_progress, + queue_goal_patch_cards, update_goal_state, +}; +use observations::{observation_prompt_line, prepare_observation_card, record_observations}; +use prefetch::Prefetch; +use validate::validate_apply_result; + +pub struct Engine { + backend: Arc, + sessions: HashMap, + context_optimizer: ContextOptimizer, + prefetch_mode: PrefetchMode, + prefetches: HashMap, + location_granter: Option, + source_context_provider: Option, +} + +/// Most open_location grants honored within a single turn before the request +/// is surfaced as a deny card instead. +pub const MAX_LOCATION_GRANTS: usize = 2; + +/// Editor callback that asks the user to open a location mid-turn and, when +/// granted, returns freshly captured context for that buffer. +pub type LocationGranter = Arc< + dyn Fn( + loopbiotic_protocol::OpenLocationCard, + String, + ) + -> std::pin::Pin> + Send>> + + Send + + Sync, +>; + +/// Editor callback that snapshots a patch target without changing the active +/// window. Goal batches use it to validate every file before review begins. +pub type SourceContextProvider = Arc< + dyn Fn( + std::path::PathBuf, + String, + ) + -> std::pin::Pin> + Send>> + + Send + + Sync, +>; + +impl Engine { + pub fn new(backend: Arc) -> Self { + Self { + backend, + sessions: HashMap::new(), + context_optimizer: ContextOptimizer::default(), + prefetch_mode: PrefetchMode::Off, + prefetches: HashMap::new(), + location_granter: None, + source_context_provider: None, + } + } + + pub fn set_prefetch_mode(&mut self, mode: PrefetchMode) { + self.prefetch_mode = mode; + } + + pub fn set_location_granter(&mut self, granter: LocationGranter) { + self.location_granter = Some(granter); + } + + pub fn set_source_context_provider(&mut self, provider: SourceContextProvider) { + self.source_context_provider = Some(provider); + } + + pub async fn start(&mut self, params: StartSessionParams) -> Result { + self.start_with_progress(params, None).await + } + + pub async fn start_with_progress( + &mut self, + params: StartSessionParams, + progress: Option, + ) -> Result { + let mut session = Session::new(params.clone()); + let context = self.optimize_context( + ContextBundle::from_start(params), + &session.original_prompt, + &session.context_policy, + ); + session.context = context.clone(); + let expected = expected_start_state(&session); + let response = self + .next_distinct_response( + &mut session, + BackendAction::Start, + context, + &expected, + progress, + None, + ) + .await; + let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); + let attempts = response.metadata.attempts.clone(); + let model = response.metadata.model.clone(); + self.add_usage(&mut session, &response.metadata.token_usage); + + let card = self.accept_response(&mut session, response, expected)?; + let session_id = session.id.clone(); + let goal = goal_progress(&session); + let token_usage = session.token_usage.clone(); + let context_report = session.context.report.clone(); + + self.sessions.insert(session_id.clone(), session); + self.schedule_prefetch(&session_id).await; + + Ok(StartSessionResult { + session_id, + card, + goal, + token_usage, + turn_token_usage, + context_report, + model, + attempts, + }) + } + + pub async fn action(&mut self, session_id: &str, action: Action) -> Result { + self.action_with_progress(session_id, action, None).await + } + + pub async fn action_with_progress( + &mut self, + session_id: &str, + action: Action, + progress: Option, + ) -> Result { + let mut session = self.take_session(session_id)?; + let prefetched = self.take_prefetch(&mut session, &action).await; + let result = self + .action_taken(session_id, &mut session, action, progress, prefetched) + .await; + + self.sessions.insert(session_id.into(), session); + if result.is_ok() { + self.schedule_prefetch(session_id).await; + } + + result + } + + pub async fn reply(&mut self, session_id: &str, text: String) -> Result { + self.reply_with_progress(session_id, text, None).await + } + + pub async fn reply_with_progress( + &mut self, + session_id: &str, + text: String, + progress: Option, + ) -> Result { + let mut session = self.take_session(session_id)?; + let result = self + .reply_taken(session_id, &mut session, text, progress) + .await; + + self.sessions.insert(session_id.into(), session); + if result.is_ok() { + self.schedule_prefetch(session_id).await; + } + + result + } + + async fn action_taken( + &self, + session_id: &str, + session: &mut Session, + action: Action, + progress: Option, + prefetched: Option, + ) -> Result { + if action == Action::ResumeDraft { + if session.state != SessionState::PatchExplained { + return Err(anyhow!("no explained patch is waiting to resume")); + } + let card = session + .cards + .iter() + .rev() + .find(|card| matches!(card, Card::Patch(_))) + .cloned() + .ok_or_else(|| anyhow!("pending patch is unavailable"))?; + session.state = SessionState::PatchShown; + session.cards.push(card.clone()); + + return Ok(ActionResult { + session_id: session_id.into(), + card, + goal: goal_progress(session), + token_usage: session.token_usage.clone(), + turn_token_usage: TokenUsage::default(), + context_report: session.context.report.clone(), + model: None, + attempts: vec![], + }); + } + + if session.state == SessionState::PatchShown + && matches!(action, Action::Retry | Action::EditPrompt) + { + session.pending_patch_cards.clear(); + } + + let state = session.state.next(&action)?; + if action == Action::Stop { + session.state = SessionState::Finished; + session.goal_status = loopbiotic_protocol::GoalStatus::Stopped; + session.next_step = None; + let card = session.stop_card(); + let token_usage = session.token_usage.clone(); + + session.cards.push(card.clone()); + + return Ok(ActionResult { + session_id: session_id.into(), + card, + goal: goal_progress(session), + token_usage, + turn_token_usage: Default::default(), + context_report: session.context.report.clone(), + model: None, + attempts: vec![], + }); + } + + let context = session.context.clone(); + + session.state = SessionState::Thinking; + + let response = self + .next_distinct_response( + session, + BackendAction::User(action), + context, + &state, + progress, + prefetched, + ) + .await; + + let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); + let attempts = response.metadata.attempts.clone(); + let model = response.metadata.model.clone(); + self.add_usage(session, &response.metadata.token_usage); + + let card = self.accept_response(session, response, state)?; + let token_usage = session.token_usage.clone(); + + Ok(ActionResult { + session_id: session_id.into(), + card, + goal: goal_progress(session), + token_usage, + turn_token_usage, + context_report: session.context.report.clone(), + model, + attempts, + }) + } + + async fn reply_taken( + &self, + session_id: &str, + session: &mut Session, + text: String, + progress: Option, + ) -> Result { + if text.trim().is_empty() { + return Err(anyhow!("reply is empty")); + } + + let context = session.context.clone(); + let expected = if session.state == SessionState::PatchExplained { + NextState::GoalWhy + } else if session.continuous_goal { + NextState::GoalLoop + } else { + NextState::Any + }; + + session.state = SessionState::Thinking; + + let response = self + .next_distinct_response( + session, + BackendAction::Reply(text), + context, + &expected, + progress, + None, + ) + .await; + + let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); + let attempts = response.metadata.attempts.clone(); + let model = response.metadata.model.clone(); + self.add_usage(session, &response.metadata.token_usage); + + let card = self.accept_response(session, response, expected)?; + let token_usage = session.token_usage.clone(); + + Ok(ActionResult { + session_id: session_id.into(), + card, + goal: goal_progress(session), + token_usage, + turn_token_usage, + context_report: session.context.report.clone(), + model, + attempts, + }) + } + + pub async fn apply_result(&mut self, result: PatchApplyResult) -> Result { + self.apply_result_with_progress(result, None).await + } + + pub async fn apply_result_with_progress( + &mut self, + result: PatchApplyResult, + progress: Option, + ) -> Result { + let mut session = self.take_session(&result.session_id)?; + let session_id = result.session_id.clone(); + let output = self + .apply_result_taken(&mut session, result, progress) + .await; + + self.sessions.insert(session_id, session); + + output + } + + async fn apply_result_taken( + &mut self, + session: &mut Session, + result: PatchApplyResult, + progress: Option, + ) -> Result { + session.state.require_patch()?; + validate_apply_result(session, &result)?; + self.context_optimizer + .invalidate(&result.context.cwd, &result.changed_files); + session.context = self.optimize_context( + result.context.clone(), + &session.original_prompt, + &session.context_policy, + ); + let session_id = result.session_id.clone(); + + if result.accepted { + let completes_goal = matches!( + session.cards.last(), + Some(Card::Patch(card)) if card.goal_complete + ); + let completed_steps = completed_patch_steps(session); + let completed_step_signatures = completed_patch_signatures(session); + session.completed_steps.extend(completed_steps); + session + .completed_step_signatures + .extend(completed_step_signatures); + session.accepted_patches.extend(result.patch_ids.clone()); + session.state = SessionState::Summary; + session.goal_status = loopbiotic_protocol::GoalStatus::NeedsReview; + session.next_step = None; + if session.continuous_goal { + if let Some(next) = session.pending_patch_cards.pop_front() { + session.state = SessionState::PatchShown; + session.goal_status = loopbiotic_protocol::GoalStatus::Active; + session.next_step = Some(next.explanation.clone()); + let card = Card::Patch(next); + session.cards.push(card.clone()); + + return Ok(ActionResult { + session_id, + card, + goal: goal_progress(session), + token_usage: session.token_usage.clone(), + turn_token_usage: TokenUsage::default(), + context_report: session.context.report.clone(), + model: None, + attempts: vec![], + }); + } + if completes_goal { + return Ok(complete_goal_locally(&session_id, session)); + } + return self + .goal_turn_taken( + &session_id, + session, + BackendAction::User(Action::Next), + progress, + ) + .await; + } + let changed_files = result.changed_files; + let summary = if changed_files.is_empty() { + "The local patch was applied. Continue only if the goal needs another change." + .into() + } else { + format!( + "Applied the local patch to {}. Continue only if the goal needs another change.", + changed_files + .iter() + .map(|file| file.display().to_string()) + .collect::>() + .join(", ") + ) + }; + let card = Card::Summary(SummaryCard { + id: session.next_card_id("applied"), + title: "Local step applied".into(), + summary, + changed_files, + next_actions: vec![Action::Next, Action::RunCheck, Action::Stop], + }); + session.cards.push(card.clone()); + + return Ok(ActionResult { + session_id, + card, + goal: goal_progress(session), + token_usage: session.token_usage.clone(), + turn_token_usage: TokenUsage::default(), + context_report: session.context.report.clone(), + model: None, + attempts: vec![], + }); + } + + session.rejected_patches.extend(result.patch_ids.clone()); + session.pending_patch_cards.clear(); + session.state = SessionState::PatchShown; + + if session.continuous_goal { + return self + .goal_turn_taken( + &session_id, + session, + BackendAction::User(Action::Retry), + progress, + ) + .await; + } + + self.action_taken(&session_id, session, Action::Retry, progress, None) + .await + } + + async fn goal_turn_taken( + &self, + session_id: &str, + session: &mut Session, + action: BackendAction, + progress: Option, + ) -> Result { + let expected = NextState::GoalLoop; + let context = session.context.clone(); + session.state = SessionState::Thinking; + let response = self + .next_distinct_response(session, action, context, &expected, progress, None) + .await; + let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); + let attempts = response.metadata.attempts.clone(); + let model = response.metadata.model.clone(); + self.add_usage(session, &response.metadata.token_usage); + let card = self.accept_response(session, response, expected)?; + + Ok(ActionResult { + session_id: session_id.into(), + card, + goal: goal_progress(session), + token_usage: session.token_usage.clone(), + turn_token_usage, + context_report: session.context.report.clone(), + model, + attempts, + }) + } + + pub fn get(&self, session_id: &str) -> Option<&Session> { + self.sessions.get(session_id) + } + + pub fn update_context(&mut self, session_id: &str, context: ContextBundle) -> Result<()> { + let (prompt, policy) = self + .sessions + .get(session_id) + .map(|session| { + ( + session.original_prompt.clone(), + session.context_policy.clone(), + ) + }) + .ok_or_else(|| anyhow!("unknown session {session_id}"))?; + let context = self.optimize_context(context, &prompt, &policy); + self.sessions + .get_mut(session_id) + .expect("session checked above") + .context = context; + + Ok(()) + } + + /// `ContextOptimizer::optimize` synchronously walks and reads up to + /// ~2000 project files, so it must not stall an async worker thread. + /// `spawn_blocking` would demand `'static` data, but the optimizer is + /// borrowed `&mut` from `self`; `block_in_place` avoids that by moving + /// this worker into blocking mode in place. NOTE: `block_in_place` + /// panics on a `current_thread` runtime — the daemon runs the default + /// multi-thread runtime, and the engine tests opt into + /// `#[tokio::test(flavor = "multi_thread")]`. + fn optimize_context( + &mut self, + context: ContextBundle, + prompt: &str, + policy: &ContextPolicy, + ) -> ContextBundle { + tokio::task::block_in_place(|| self.context_optimizer.optimize(context, prompt, policy)) + } + + fn request( + &self, + session: &Session, + action: BackendAction, + context: ContextBundle, + expected: &NextState, + ) -> BackendRequest { + let expected_kind = expected_card_kind(session, &action, expected); + + let allow_goal_completion = matches!(expected, NextState::GoalLoop | NextState::GoalWhy); + let mut card_contract = CardContract { + expected_kind, + allow_goal_completion, + ..CardContract::default() + }; + if allow_goal_completion && !matches!(expected, NextState::GoalWhy) { + card_contract.max_patch_files = MAX_GOAL_PATCH_FILES; + card_contract.max_hunks_per_patch = MAX_GOAL_HUNKS_PER_PATCH; + card_contract.max_changed_lines = MAX_GOAL_CHANGED_LINES; + } + + BackendRequest { + session: SessionSnapshot { + id: session.id.clone(), + prompt: session.original_prompt.clone(), + completed_steps: session.completed_steps.clone(), + known_observations: session + .known_observations + .iter() + .map(observation_prompt_line) + .collect(), + mode: session.mode.clone(), + card_count: session.cards.len(), + last_card: session.cards.last().cloned(), + last_summary: session.cards.last().map(card_summary), + }, + action, + context, + card_contract, + } + } + + fn accept_response( + &self, + session: &mut Session, + response: loopbiotic_backends::BackendResponse, + next_state: NextState, + ) -> Result { + let mut received = response.card; + if let Card::Patch(patch) = &mut received + && !patch.actions.contains(&Action::Why) + { + patch.actions.insert(1, Action::Why); + } + if matches!(next_state, NextState::GoalWhy) + && let Card::Finding(finding) = &mut received + { + finding.actions = vec![Action::ResumeDraft, Action::Stop]; + } + if matches!(next_state, NextState::GoalLoop) + && let Card::Summary(summary) = &mut received + { + summary + .next_actions + .retain(|action| *action != Action::Next); + if !summary.next_actions.contains(&Action::RunCheck) { + summary.next_actions.insert(0, Action::RunCheck); + } + if !summary.next_actions.contains(&Action::Stop) { + summary.next_actions.push(Action::Stop); + } + } + if !matches!(next_state, NextState::GoalWhy) { + prepare_observation_card(session, &mut received); + } + PatchCoherence::annotate(&mut received); + if !matches!(next_state, NextState::GoalWhy) { + record_observations(session, &received); + } + let card = if matches!(next_state, NextState::GoalLoop) { + queue_goal_patch_cards(session, received)? + } else { + received + }; + + update_goal_state(session, &card, &next_state); + session.state = state_after_card(&card, &next_state); + session.cards.push(card.clone()); + + Ok(card) + } + + fn take_session(&mut self, session_id: &str) -> Result { + self.sessions + .remove(session_id) + .ok_or_else(|| anyhow!("unknown session {session_id}")) + } + + fn add_usage(&self, session: &mut Session, usage: &Option) { + if let Some(usage) = usage { + session.token_usage.add(usage); + } + } +} + +fn expected_start_state(session: &Session) -> NextState { + if session.continuous_goal { + NextState::GoalLoop + } else if session.mode == Mode::Fix { + NextState::Patch + } else { + NextState::Any + } +} + +/// None means the agent may answer with whichever card kind fits, including a +/// clarifying choice or a deny. A kind is only demanded when the user asked +/// for one (a "/{kind}" prompt prefix, an explicit mode, or a concrete action +/// such as Fix) or when the state machine requires it. +fn expected_card_kind( + session: &Session, + action: &BackendAction, + next_state: &NextState, +) -> Option { + match next_state { + NextState::Patch => return Some(CardKind::Patch), + NextState::GoalLoop => return None, + NextState::GoalWhy => return Some(CardKind::Finding), + NextState::Summary | NextState::Finished => return Some(CardKind::Summary), + NextState::Any | NextState::Card => {} + } + + match action { + BackendAction::Start => session.forced_kind.or(match session.mode { + Mode::Fix | Mode::Propose => Some(CardKind::Patch), + Mode::Explain | Mode::Review => Some(CardKind::Finding), + Mode::Investigate => Some(CardKind::Hypothesis), + Mode::Auto => None, + }), + BackendAction::Reply(_) => None, + BackendAction::ContractRetry(_) => None, + BackendAction::LocationGranted => None, + BackendAction::User(action) => match action { + Action::Fix => Some(CardKind::Patch), + Action::OtherLead => Some(CardKind::Hypothesis), + Action::Follow | Action::Why | Action::Open | Action::RunCheck | Action::Next => { + Some(CardKind::Finding) + } + Action::Retry | Action::EditPrompt => session + .cards + .iter() + .rev() + .find(|card| !matches!(card, Card::Error(_) | Card::Deny(_))) + .map(Card::kind) + .or(session.forced_kind), + Action::Apply | Action::ApplyPatch { .. } | Action::ResumeDraft | Action::Stop => { + Some(CardKind::Summary) + } + }, + } +} + +fn state_after_card(card: &Card, next_state: &NextState) -> SessionState { + let refused = matches!(card, Card::Error(_) | Card::Deny(_)); + if refused && matches!(next_state, NextState::Patch) { + return SessionState::PatchFailed; + } + if refused && matches!(next_state, NextState::GoalLoop) { + return SessionState::GoalLoopFailed; + } + if refused && matches!(next_state, NextState::GoalWhy) { + return SessionState::PatchExplained; + } + if matches!(next_state, NextState::GoalWhy) && matches!(card, Card::Finding(_)) { + return SessionState::PatchExplained; + } + + SessionState::from_card(card) +} + +fn card_summary(card: &Card) -> String { + match card { + Card::Hypothesis(card) => format!("hypothesis: {}", card.claim), + Card::Finding(card) => format!("finding: {}", card.finding), + Card::Patch(card) => format!("patch: {}", card.explanation), + Card::Choice(card) => format!("choice: {}", card.question), + Card::Deny(card) => format!("deny: {}", card.reason), + Card::OpenLocation(card) => format!("open_location: {}", card.reason), + Card::Summary(card) => format!("summary: {}", card.summary), + Card::Error(card) => format!("error: {}", card.message), + } +} + +impl Session { + fn stop_card(&self) -> Card { + Card::Summary(SummaryCard { + id: self.next_card_id("stop"), + title: "Stopped".into(), + summary: "Session stopped.".into(), + changed_files: vec![], + next_actions: vec![], + }) + } +} diff --git a/rust/crates/loopbiotic_harness/src/engine/observations.rs b/rust/crates/loopbiotic_harness/src/engine/observations.rs new file mode 100644 index 0000000..356f964 --- /dev/null +++ b/rust/crates/loopbiotic_harness/src/engine/observations.rs @@ -0,0 +1,161 @@ +//! Session observation memory: recording hypothesis/finding/signal +//! observations, deduplicating repeats, and rendering them for prompts. + +use loopbiotic_protocol::{Card, ObservationKind, ObservationProgress}; + +use crate::session::Session; + +pub(super) fn prepare_observation_card(session: &mut Session, card: &mut Card) { + if core_observation(card).is_some() { + for observation in &mut session.known_observations { + observation.active = false; + } + } + + match card { + Card::Hypothesis(card) => { + if let Some(evidence) = &mut card.evidence + && !evidence.annotation.trim().is_empty() + { + let key = observation_key(ObservationKind::Signal, &evidence.annotation); + if session.observation_index.contains_key(&key) { + activate_observation(session, &key); + evidence.annotation.clear(); + } + } + } + Card::Finding(card) => { + if let Some(annotation) = card + .annotation + .clone() + .filter(|text| !text.trim().is_empty()) + { + let key = observation_key(ObservationKind::Signal, &annotation); + if session.observation_index.contains_key(&key) { + activate_observation(session, &key); + card.annotation = None; + } + } + } + _ => {} + } +} + +pub(super) fn record_observations(session: &mut Session, card: &Card) { + if let Some((key, kind, label)) = core_observation(card) { + record_observation(session, key, kind, label); + } + + match card { + Card::Hypothesis(card) => { + if let Some(evidence) = &card.evidence + && !evidence.annotation.trim().is_empty() + { + let label = evidence.annotation.clone(); + record_observation( + session, + observation_key(ObservationKind::Signal, &label), + ObservationKind::Signal, + label, + ); + } + } + Card::Finding(card) => { + if let Some(label) = card + .annotation + .clone() + .filter(|text| !text.trim().is_empty()) + { + record_observation( + session, + observation_key(ObservationKind::Signal, &label), + ObservationKind::Signal, + label, + ); + } + } + _ => {} + } +} + +pub(super) fn core_observation(card: &Card) -> Option<(String, ObservationKind, String)> { + let (kind, label) = match card { + Card::Hypothesis(card) => (ObservationKind::Hypothesis, card.claim.clone()), + Card::Finding(card) => (ObservationKind::Finding, card.finding.clone()), + _ => return None, + }; + + Some((observation_key(kind, &label), kind, label)) +} + +fn record_observation(session: &mut Session, key: String, kind: ObservationKind, label: String) { + if let Some(index) = session.observation_index.get(&key).copied() { + if let Some(observation) = session.known_observations.get_mut(index) { + observation.occurrences += 1; + observation.active = true; + } + return; + } + + let index = session.known_observations.len(); + session.observation_index.insert(key, index); + session.known_observations.push(ObservationProgress { + id: format!("o_{}", index + 1), + kind, + label, + occurrences: 1, + active: true, + }); +} + +pub(super) fn activate_observation(session: &mut Session, key: &str) { + let Some(index) = session.observation_index.get(key).copied() else { + return; + }; + if let Some(observation) = session.known_observations.get_mut(index) { + observation.occurrences += 1; + observation.active = true; + } +} + +fn observation_key(kind: ObservationKind, label: &str) -> String { + format!( + "{}:{}", + observation_kind_name(kind), + normalize_observation(label) + ) +} + +pub(super) fn normalize_observation(text: &str) -> String { + text.to_lowercase() + .chars() + .map(|character| { + if character.is_alphanumeric() { + character + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn observation_kind_name(kind: ObservationKind) -> &'static str { + match kind { + ObservationKind::Hypothesis => "hypothesis", + ObservationKind::Finding => "finding", + ObservationKind::Signal => "signal", + } +} + +pub(super) fn observation_prompt_line(observation: &ObservationProgress) -> String { + format!( + "{} {} (seen {}x): {}", + observation.id, + observation_kind_name(observation.kind), + observation.occurrences, + observation.label + ) +} diff --git a/rust/crates/loopbiotic_harness/src/engine/prefetch.rs b/rust/crates/loopbiotic_harness/src/engine/prefetch.rs new file mode 100644 index 0000000..4f34e66 --- /dev/null +++ b/rust/crates/loopbiotic_harness/src/engine/prefetch.rs @@ -0,0 +1,159 @@ +//! Speculative prefetch of the likely next card while the user is still +//! reading the current one. + +use anyhow::Result; +use loopbiotic_backends::{BackendAction, BackendRequest, BackendResponse}; +use loopbiotic_protocol::Action; + +use crate::session::Session; +use crate::state::SessionState; + +use super::Engine; + +/// Speculative prefetch of the likely next card. `Fix` requests the patch +/// card in the background while the user is still reading a discovery card, +/// so pressing Fix returns (near-)instantly on a hit. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrefetchMode { + Off, + Fix, +} + +pub(super) struct Prefetch { + action: Action, + fingerprint: u64, + handle: tokio::task::JoinHandle>, +} + +impl Engine { + /// Requests the likely next card in the background while the user reads + /// the one just shown. Only Fix is predicted: it is the most common and + /// slowest follow-up, and on backends with a separate patch process a + /// misprediction never blocks the user's real next request. + pub(super) async fn schedule_prefetch(&mut self, session_id: &str) { + if self.prefetch_mode != PrefetchMode::Fix { + return; + } + + if let Some(existing) = self.prefetches.get(session_id) { + if !existing.handle.is_finished() { + // An earlier speculation is still running on the backend; + // queueing another would only pile up turns. + return; + } + // Fold the finished-but-unconsumed speculation into the session's + // token totals so wasted turns stay visible to the user. + if let Some(stale) = self.prefetches.remove(session_id) + && let Ok(Ok(response)) = stale.handle.await + && let Some(session) = self.sessions.get_mut(session_id) + { + fold_usage(session, &response.metadata.token_usage); + } + } + + let Some(session) = self.sessions.get(session_id) else { + return; + }; + if session.state != SessionState::CardShown { + return; + } + let Some(card) = session.cards.last() else { + return; + }; + if !card.actions().contains(&Action::Fix) { + return; + } + let Ok(expected) = session.state.next(&Action::Fix) else { + return; + }; + + let request = self.request( + session, + BackendAction::User(Action::Fix), + session.context.clone(), + &expected, + ); + let fingerprint = request_fingerprint(&request); + let backend = self.backend.clone(); + let handle = tokio::spawn(async move { backend.next_card(request).await }); + + self.prefetches.insert( + session_id.to_string(), + Prefetch { + action: Action::Fix, + fingerprint, + handle, + }, + ); + } + + /// Consumes a pending speculation if it was computed for exactly the + /// request this action would produce; otherwise leaves the real path + /// untouched and keeps the wasted tokens accounted for. + pub(super) async fn take_prefetch( + &mut self, + session: &mut Session, + action: &Action, + ) -> Option { + let entry = self.prefetches.remove(&session.id)?; + + if entry.action == *action + && let Ok(expected) = session.state.next(action) + { + let request = self.request( + session, + BackendAction::User(action.clone()), + session.context.clone(), + &expected, + ); + if request_fingerprint(&request) == entry.fingerprint { + return match entry.handle.await { + Ok(Ok(response)) => Some(response), + _ => None, + }; + } + if entry.handle.is_finished() { + if let Ok(Ok(response)) = entry.handle.await { + fold_usage(session, &response.metadata.token_usage); + } + return None; + } + self.prefetches.insert(session.id.clone(), entry); + return None; + } + + if entry.handle.is_finished() { + if let Ok(Ok(response)) = entry.handle.await { + fold_usage(session, &response.metadata.token_usage); + } + } else { + self.prefetches.insert(session.id.clone(), entry); + } + + None + } +} + +fn fold_usage(session: &mut Session, usage: &Option) { + if let Some(usage) = usage { + session.token_usage.add(usage); + } +} + +pub(super) fn request_fingerprint(request: &BackendRequest) -> u64 { + use std::hash::{DefaultHasher, Hash, Hasher}; + + // Only model-visible data may decide whether a speculative response + // matches: the optimizer report (cache counters vary run to run) and raw + // LSP hints are telemetry that backend_context strips before the model + // ever sees them. + let mut request = request.clone(); + request.context.report = None; + request.context.hints = vec![]; + + let mut hasher = DefaultHasher::new(); + serde_json::to_string(&request) + .unwrap_or_default() + .hash(&mut hasher); + hasher.finish() +} diff --git a/rust/crates/loopbiotic_harness/src/engine/tests.rs b/rust/crates/loopbiotic_harness/src/engine/tests.rs new file mode 100644 index 0000000..2fc6a55 --- /dev/null +++ b/rust/crates/loopbiotic_harness/src/engine/tests.rs @@ -0,0 +1,1327 @@ +//! Engine-level tests that drive full turns across the turn/prefetch/goal/ +//! validate submodules, plus the shared fixtures they use. +//! +//! All async tests use the multi-thread runtime flavor because +//! `Engine::optimize_context` calls `tokio::task::block_in_place`, which +//! panics on the current-thread runtime `#[tokio::test]` defaults to. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use async_trait::async_trait; +use loopbiotic_backends::{ + BackendAction, BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, MockBackend, +}; +use loopbiotic_protocol::{ + BackendInfo, Cursor, FilePatch, FindingCard, HypothesisCard, Mode, PatchCard, +}; + +use super::prefetch::request_fingerprint; +use super::*; + +pub(super) fn params() -> StartSessionParams { + StartSessionParams { + cwd: PathBuf::from("/tmp/project"), + file: PathBuf::from("src/work.ts"), + cursor: Cursor { line: 1, column: 1 }, + selection: None, + prompt: "payload is empty".into(), + mode: Mode::Investigate, + buffer_text: "placeholder".into(), + buffer_start_line: 1, + diagnostics: vec![], + hints: vec![], + context_policy: Default::default(), + } +} + +fn editor_context(buffer_text: &str) -> ContextBundle { + ContextBundle { + cwd: PathBuf::from("/tmp/project"), + file: PathBuf::from("src/work.ts"), + cursor: Cursor { line: 1, column: 1 }, + selection: None, + buffer_text: buffer_text.into(), + buffer_start_line: 1, + diagnostics: vec![], + hints: vec![], + artifacts: vec![], + report: None, + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn starts_with_hypothesis() { + let backend = Arc::new(MockBackend); + let mut engine = Engine::new(backend); + + let result = engine.start(params()).await.unwrap(); + + assert!(matches!(result.card, Card::Hypothesis(_))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn rejects_apply_before_patch() { + let backend = Arc::new(MockBackend); + let mut engine = Engine::new(backend); + let result = engine.start(params()).await.unwrap(); + + let error = engine + .action(&result.session_id, Action::Apply) + .await + .unwrap_err(); + + assert!(error.to_string().contains("invalid action")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn continuous_goal_reviews_a_complete_batch_without_more_model_turns() { + let backend = Arc::new(BatchGoalBackend::default()); + let mut engine = Engine::new(backend.clone()); + let mut goal = params(); + goal.mode = Mode::Auto; + goal.buffer_text = "first\nmiddle\nlast".into(); + let first = engine.start(goal).await.unwrap(); + + let Card::Patch(first_patch) = &first.card else { + panic!("expected first review hunk"); + }; + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + + let result = PatchApplyResult { + session_id: first.session_id, + card_id: first.card.id().into(), + accepted: true, + patch_ids: vec![first_patch.patches[0].id.clone()], + changed_files: vec![PathBuf::from("src/work.ts")], + error: None, + context: editor_context("FIRST\nmiddle\nlast"), + }; + + let second = engine.apply_result(result).await.unwrap(); + + assert!(matches!(second.card, Card::Patch(_))); + assert_eq!(second.turn_token_usage.total_tokens, 0); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + assert_eq!(second.goal.status, loopbiotic_protocol::GoalStatus::Active); + assert_eq!( + engine + .get(&second.session_id) + .unwrap() + .completed_steps + .len(), + 1 + ); + + let Card::Patch(second_patch) = &second.card else { + panic!("expected second review hunk"); + }; + let result = PatchApplyResult { + session_id: second.session_id, + card_id: second.card.id().into(), + accepted: true, + patch_ids: vec![second_patch.patches[0].id.clone()], + changed_files: vec![PathBuf::from("src/work.ts")], + error: None, + context: editor_context("FIRST\nmiddle\nLAST"), + }; + let complete = engine.apply_result(result).await.unwrap(); + + assert!(matches!(complete.card, Card::Summary(_))); + assert_eq!(complete.turn_token_usage.total_tokens, 0); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + assert_eq!( + complete.goal.status, + loopbiotic_protocol::GoalStatus::Complete + ); + assert_eq!(complete.goal.completed_steps.len(), 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn continuous_goal_reviews_a_multi_file_batch_without_more_model_turns() { + let backend = Arc::new(MultiFileGoalBackend::default()); + let reads = Arc::new(AtomicUsize::new(0)); + let mut engine = Engine::new(backend.clone()); + let observed_reads = reads.clone(); + engine.set_source_context_provider(Arc::new(move |file, _session_id| { + let observed_reads = observed_reads.clone(); + Box::pin(async move { + observed_reads.fetch_add(1, Ordering::SeqCst); + let text = if file == std::path::Path::new("src/work.ts") { + "first" + } else { + "other" + }; + let mut context = editor_context(text); + context.file = file; + Some(context) + }) + })); + let mut goal = params(); + goal.mode = Mode::Auto; + goal.buffer_text = "first".into(); + + let first = engine.start(goal).await.unwrap(); + let Card::Patch(first_patch) = &first.card else { + panic!( + "expected first file hunk, got {:?}; attempts {:?}", + first.card, first.attempts + ); + }; + assert_eq!(first_patch.patches[0].file, PathBuf::from("src/work.ts")); + assert!(!first_patch.goal_complete); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + assert_eq!(reads.load(Ordering::SeqCst), 2); + + let second = engine + .apply_result(PatchApplyResult { + session_id: first.session_id, + card_id: first.card.id().into(), + accepted: true, + patch_ids: vec![first_patch.patches[0].id.clone()], + changed_files: vec![PathBuf::from("src/work.ts")], + error: None, + context: editor_context("FIRST"), + }) + .await + .unwrap(); + let Card::Patch(second_patch) = &second.card else { + panic!("expected second file hunk"); + }; + assert_eq!(second_patch.patches[0].file, PathBuf::from("src/other.ts")); + assert!(second_patch.goal_complete); + assert_eq!(second.turn_token_usage.total_tokens, 0); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + + let mut other_context = editor_context("OTHER"); + other_context.file = PathBuf::from("src/other.ts"); + let complete = engine + .apply_result(PatchApplyResult { + session_id: second.session_id, + card_id: second.card.id().into(), + accepted: true, + patch_ids: vec![second_patch.patches[0].id.clone()], + changed_files: vec![PathBuf::from("src/other.ts")], + error: None, + context: other_context, + }) + .await + .unwrap(); + + assert!(matches!(complete.card, Card::Summary(_))); + assert_eq!( + complete.goal.status, + loopbiotic_protocol::GoalStatus::Complete + ); + assert_eq!(complete.turn_token_usage.total_tokens, 0); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn continuous_goal_reworks_a_rejected_hunk_without_leaving_the_loop() { + let backend = Arc::new(MockBackend); + let mut engine = Engine::new(backend); + let mut goal = params(); + goal.mode = Mode::Auto; + let first = engine.start(goal).await.unwrap(); + let Card::Patch(first_patch) = &first.card else { + panic!("expected patch card"); + }; + let result = PatchApplyResult { + session_id: first.session_id, + card_id: first.card.id().into(), + accepted: false, + patch_ids: vec![first_patch.patches[0].id.clone()], + changed_files: vec![], + error: None, + context: editor_context("placeholder"), + }; + + let reworked = engine.apply_result(result).await.unwrap(); + + assert!(matches!(reworked.card, Card::Patch(_))); + assert!(reworked.turn_token_usage.total_tokens > 0); + assert!(reworked.goal.completed_steps.is_empty()); + assert_eq!( + reworked.goal.status, + loopbiotic_protocol::GoalStatus::Active + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn why_explains_and_restores_the_same_pending_hunk() { + let backend = Arc::new(MockBackend); + let mut engine = Engine::new(backend); + let mut goal = params(); + goal.mode = Mode::Auto; + let first = engine.start(goal).await.unwrap(); + let patch_id = first.card.id().to_string(); + + let explained = engine.action(&first.session_id, Action::Why).await.unwrap(); + + let Card::Finding(explanation) = explained.card else { + panic!("expected patch explanation"); + }; + assert!(explanation.actions.contains(&Action::ResumeDraft)); + assert_eq!( + engine.get(&first.session_id).unwrap().state, + SessionState::PatchExplained + ); + + let resumed = engine + .action(&first.session_id, Action::ResumeDraft) + .await + .unwrap(); + + assert!(matches!(resumed.card, Card::Patch(_))); + assert_eq!(resumed.card.id(), patch_id); + assert_eq!(resumed.turn_token_usage, TokenUsage::default()); + assert_eq!( + engine.get(&first.session_id).unwrap().state, + SessionState::PatchShown + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn rejects_apply_result_for_another_patch_card() { + let backend = Arc::new(MockBackend); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + let patch = engine.action(&start.session_id, Action::Fix).await.unwrap(); + let result = PatchApplyResult { + session_id: start.session_id.clone(), + card_id: "c_stale".into(), + accepted: true, + patch_ids: vec!["p_1".into()], + changed_files: vec![PathBuf::from("src/work.ts")], + error: None, + context: editor_context("payload = payload or {}"), + }; + + let error = engine.apply_result(result).await.unwrap_err(); + + assert!(error.to_string().contains("current patch card")); + assert_eq!( + engine.get(&start.session_id).unwrap().state, + SessionState::PatchShown + ); + assert_eq!(patch.card.id(), "c_patch"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn rejected_apply_returns_reworked_patch_without_summary() { + let backend = Arc::new(MockBackend); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + let patch = engine.action(&start.session_id, Action::Fix).await.unwrap(); + let result = PatchApplyResult { + session_id: start.session_id.clone(), + card_id: patch.card.id().into(), + accepted: false, + patch_ids: vec!["p_1".into()], + changed_files: vec![], + error: Some("patch context is ambiguous".into()), + context: editor_context("placeholder"), + }; + + let reworked = engine.apply_result(result).await.unwrap(); + + assert!(matches!(reworked.card, Card::Patch(_))); + assert_eq!( + engine.get(&start.session_id).unwrap().state, + SessionState::PatchShown + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn rejects_apply_result_with_unreported_target_file() { + let backend = Arc::new(MockBackend); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + let patch = engine.action(&start.session_id, Action::Fix).await.unwrap(); + let result = PatchApplyResult { + session_id: start.session_id.clone(), + card_id: patch.card.id().into(), + accepted: true, + patch_ids: vec!["p_1".into()], + changed_files: vec![PathBuf::from("src/other.ts")], + error: None, + context: editor_context("payload = payload or {}"), + }; + + let error = engine.apply_result(result).await.unwrap_err(); + + assert!(error.to_string().contains("changed files")); + assert!( + engine + .get(&start.session_id) + .unwrap() + .accepted_patches + .is_empty() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn returns_typed_card_and_keeps_session_after_backend_error() { + let backend = Arc::new(FlakyBackend::default()); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + let failed = engine + .action(&start.session_id, Action::Follow) + .await + .unwrap(); + + let Card::Error(error) = failed.card else { + panic!("expected error card"); + }; + assert!(error.message.contains("backend failed")); + + let next = engine.action(&start.session_id, Action::Why).await.unwrap(); + + assert!(matches!(next.card, Card::Finding(_))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn converts_bad_patch_to_error_card() { + let backend = Arc::new(BadPatchBackend); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + + let Card::Error(card) = result.card else { + panic!("expected error card"); + }; + + assert!(card.message.contains("diff has no hunks")); + + let retry = engine + .action(&start.session_id, Action::Retry) + .await + .unwrap(); + + assert!(matches!(retry.card, Card::Error(_))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn repairs_invalid_patch_before_showing_it_to_user() { + let backend = Arc::new(RepairingPatchBackend::default()); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + + let Card::Patch(card) = result.card else { + panic!("expected repaired patch card"); + }; + assert_eq!( + card.patches[0].diff, + "@@ -1,1 +1,1 @@\n-placeholder\n+repaired\n" + ); + assert_eq!(result.attempts.len(), 2); + assert_eq!(result.attempts[0].outcome, "contract_retry"); + assert!( + result.attempts[0] + .detail + .as_deref() + .unwrap() + .contains("patch context was not found") + ); + assert!(result.attempts[0].candidate_card.is_some()); + assert_eq!(result.attempts[0].token_usage.total_tokens, 15); + assert_eq!(result.attempts[1].outcome, "accepted"); + assert_eq!(result.turn_token_usage.total_tokens, 30); +} + +#[tokio::test(flavor = "multi_thread")] +async fn preserves_wrong_card_type_and_raw_backend_output_for_fix() { + let backend = Arc::new(WrongTypeBackend); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + + let Card::Error(card) = result.card else { + panic!("expected error card"); + }; + + assert!(card.message.contains("expected patch card")); + assert!(card.message.contains("Received card kind: Finding")); + assert!(card.message.contains("raw finding from backend")); + + let retry = engine + .action(&start.session_id, Action::Retry) + .await + .unwrap(); + assert!(matches!(retry.card, Card::Error(_))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn start_in_fix_mode_requires_a_patch_card() { + let backend = Arc::new(WrongTypeBackend); + let mut engine = Engine::new(backend); + let mut fix_params = params(); + fix_params.mode = Mode::Fix; + + let result = engine.start(fix_params).await.unwrap(); + let Card::Error(card) = result.card else { + panic!("expected error card"); + }; + + assert!(card.message.contains("expected the next goal patch")); + assert_eq!( + engine.get(&result.session_id).unwrap().state, + SessionState::GoalLoopFailed + ); + let apply_error = engine + .action(&result.session_id, Action::Apply) + .await + .unwrap_err(); + assert!(apply_error.to_string().contains("invalid action")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn start_returns_typed_card_when_backend_fails() { + let backend = Arc::new(AlwaysFailBackend); + let mut engine = Engine::new(backend); + + let result = engine.start(params()).await.unwrap(); + let Card::Error(card) = result.card else { + panic!("expected error card"); + }; + + assert!(card.message.contains("backend unavailable")); + assert_eq!(result.turn_token_usage, Default::default()); + assert_eq!(result.attempts[0].outcome, "backend_error"); + assert!( + result.attempts[0] + .detail + .as_deref() + .unwrap() + .contains("token limit reached") + ); + assert!(engine.get(&result.session_id).is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn retains_duplicate_observations_without_showing_them_again() { + let backend = Arc::new(RepeatingObservationBackend::default()); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + + let result = engine + .action(&start.session_id, Action::Follow) + .await + .unwrap(); + + let Card::Finding(card) = result.card else { + panic!("expected distinct finding after automatic contract retry"); + }; + assert_eq!(card.finding, "The caller still consumes the old shape."); + assert_eq!(card.annotation, None); + assert_eq!(result.attempts.len(), 2); + assert_eq!(result.attempts[0].outcome, "duplicate_retry"); + assert!(result.attempts[0].candidate_card.is_some()); + assert_eq!(result.attempts[1].outcome, "accepted"); + + let observations = &engine.get(&start.session_id).unwrap().known_observations; + assert_eq!(observations.len(), 3); + assert_eq!(observations[0].occurrences, 2); + assert_eq!(observations[1].occurrences, 2); + assert!(!observations[0].active); + assert!(observations[1].active); + assert!(observations[2].active); +} + +#[tokio::test(flavor = "multi_thread")] +async fn replies_inside_session() { + let backend = Arc::new(MockBackend); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + let result = engine + .reply(&start.session_id, "that is not it".into()) + .await + .unwrap(); + + let Card::Finding(card) = result.card else { + panic!("expected finding card"); + }; + + assert!(card.finding.contains("that is not it")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn action_uses_refreshed_editor_context() { + let backend = Arc::new(MockBackend); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + let context = ContextBundle { + cwd: PathBuf::from("/tmp/project"), + file: PathBuf::from("templates/layout.html"), + cursor: Cursor { + line: 12, + column: 1, + }, + selection: None, + buffer_text: "{{ block.preview_html|safe }}".into(), + buffer_start_line: 1, + diagnostics: vec![], + hints: vec![], + artifacts: vec![], + report: None, + }; + + engine.update_context(&start.session_id, context).unwrap(); + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + let Card::Patch(card) = result.card else { + panic!("expected patch card"); + }; + + assert_eq!(card.patches[0].file, PathBuf::from("templates/layout.html")); +} + +#[derive(Default)] +struct FlakyBackend { + failed: AtomicBool, +} + +#[derive(Default)] +struct BatchGoalBackend { + calls: AtomicUsize, +} + +#[async_trait] +impl BackendAdapter for BatchGoalBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + assert!(req.card_contract.allow_goal_completion); + assert_eq!(req.card_contract.max_patch_files, MAX_GOAL_PATCH_FILES); + assert_eq!( + req.card_contract.max_hunks_per_patch, + MAX_GOAL_HUNKS_PER_PATCH + ); + assert_eq!(req.card_contract.max_changed_lines, MAX_GOAL_CHANGED_LINES); + + Ok(BackendResponse { + card: Card::Patch(PatchCard { + id: "c_batch".into(), + title: "Complete local change".into(), + explanation: "Prepare both independent edits.".into(), + warnings: vec![], + goal_complete: true, + patches: vec![FilePatch { + id: "p_batch".into(), + file: "src/work.ts".into(), + diff: "@@ -1,2 +1,2 @@\n-first\n+FIRST\n middle\n@@ -2,2 +2,2 @@\n middle\n-last\n+LAST\n".into(), + explanation: "Updates both required locations.".into(), + }], + actions: vec![Action::Apply, Action::Why, Action::Retry, Action::Stop], + }), + raw_output: None, + metadata: BackendMetadata { + backend: "batch_goal".into(), + model: None, + token_usage: Some(TokenUsage::estimated(100, 20)), + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + MockBackend::info() + } +} + +#[derive(Default)] +struct MultiFileGoalBackend { + calls: AtomicUsize, +} + +#[async_trait] +impl BackendAdapter for MultiFileGoalBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(req.card_contract.max_patch_files, MAX_GOAL_PATCH_FILES); + + Ok(BackendResponse { + card: Card::Patch(PatchCard { + id: "c_multi".into(), + title: "Complete workspace change".into(), + explanation: "Update both required files.".into(), + warnings: vec![], + goal_complete: true, + patches: vec![ + FilePatch { + id: "p_work".into(), + file: "src/work.ts".into(), + diff: "@@ -1,1 +1,1 @@\n-first\n+FIRST\n".into(), + explanation: "Update the producer.".into(), + }, + FilePatch { + id: "p_other".into(), + file: "src/other.ts".into(), + diff: "@@ -1,1 +1,1 @@\n-other\n+OTHER\n".into(), + explanation: "Update the consumer.".into(), + }, + ], + actions: vec![Action::Apply, Action::Why, Action::Retry, Action::Stop], + }), + raw_output: None, + metadata: BackendMetadata { + backend: "multi_file_goal".into(), + model: None, + token_usage: Some(TokenUsage::estimated(100, 20)), + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + MockBackend::info() + } +} + +struct BadPatchBackend; + +#[derive(Default)] +struct RepairingPatchBackend { + failed_once: AtomicBool, +} + +struct WrongTypeBackend; + +struct AlwaysFailBackend; + +#[derive(Default)] +struct RepeatingObservationBackend { + calls: std::sync::atomic::AtomicUsize, +} + +#[async_trait] +impl BackendAdapter for RepeatingObservationBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + let card = match (call, req.action) { + (0, BackendAction::Start) | (1, BackendAction::User(Action::Follow)) => { + Card::Hypothesis(HypothesisCard { + id: format!("c_repeat_{call}"), + title: "Repeated branch".into(), + claim: "The branch returns before building the preview.".into(), + evidence: Some(loopbiotic_protocol::LocationEvidence { + file: "src/work.ts".into(), + line: 1, + column: 1, + annotation: "The preview is skipped here.".into(), + }), + next_move: None, + actions: vec![Action::Follow, Action::Fix, Action::Stop], + }) + } + (2, BackendAction::ContractRetry(_)) => Card::Finding(FindingCard { + id: "c_distinct".into(), + title: "Consumer remains".into(), + finding: "The caller still consumes the old shape.".into(), + location: Some(loopbiotic_protocol::Location { + file: "src/work.ts".into(), + line: 1, + column: 1, + }), + annotation: Some("The preview is skipped here.".into()), + actions: vec![Action::Fix, Action::Stop], + }), + _ => panic!("unexpected observation backend request"), + }; + + Ok(BackendResponse { + card, + raw_output: None, + metadata: BackendMetadata { + backend: "repeating_observation".into(), + model: None, + token_usage: Some(loopbiotic_protocol::TokenUsage::estimated(10, 5)), + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "repeating_observation".into(), + streaming: false, + patches: true, + reasoning: false, + can_read_project: false, + can_use_tools: false, + } + } +} + +#[async_trait] +impl BackendAdapter for AlwaysFailBackend { + async fn next_card(&self, _req: BackendRequest) -> Result { + Err(anyhow!("backend unavailable: token limit reached")) + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "always_fail".into(), + streaming: false, + patches: false, + reasoning: false, + can_read_project: false, + can_use_tools: false, + } + } +} + +#[async_trait] +impl BackendAdapter for FlakyBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + if matches!(req.action, BackendAction::User(Action::Follow)) + && !self.failed.swap(true, Ordering::SeqCst) + { + return Err(anyhow!("backend failed")); + } + + let card = match req.action { + BackendAction::Start => Card::Hypothesis(HypothesisCard { + id: "c_1".into(), + title: "Start".into(), + claim: "Initial claim.".into(), + evidence: None, + next_move: None, + actions: vec![Action::Follow, Action::Why, Action::Stop], + }), + _ => Card::Finding(FindingCard { + id: "c_2".into(), + title: "Recovered".into(), + finding: "Session still works.".into(), + location: None, + annotation: None, + actions: vec![Action::Stop], + }), + }; + + Ok(BackendResponse { + card, + raw_output: None, + metadata: BackendMetadata { + backend: "flaky".into(), + model: None, + token_usage: None, + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "flaky".into(), + streaming: false, + patches: false, + reasoning: false, + can_read_project: false, + can_use_tools: false, + } + } +} + +#[async_trait] +impl BackendAdapter for BadPatchBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + let card = match req.action { + BackendAction::Start => Card::Hypothesis(HypothesisCard { + id: "c_1".into(), + title: "Start".into(), + claim: "Initial claim.".into(), + evidence: None, + next_move: None, + actions: vec![Action::Fix, Action::Stop], + }), + _ => Card::Patch(PatchCard { + id: "c_patch".into(), + title: "Bad patch".into(), + explanation: "Invalid patch.".into(), + warnings: vec![], + goal_complete: false, + patches: vec![FilePatch { + id: "p_1".into(), + file: "src/work.ts".into(), + diff: "not a unified diff".into(), + explanation: "Broken.".into(), + }], + actions: vec![Action::Apply, Action::Retry, Action::Stop], + }), + }; + + Ok(BackendResponse { + card, + raw_output: None, + metadata: BackendMetadata { + backend: "bad_patch".into(), + model: None, + token_usage: None, + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "bad_patch".into(), + streaming: false, + patches: true, + reasoning: false, + can_read_project: false, + can_use_tools: false, + } + } +} + +#[async_trait] +impl BackendAdapter for RepairingPatchBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + let card = match req.action { + BackendAction::Start => Card::Hypothesis(HypothesisCard { + id: "c_1".into(), + title: "Start".into(), + claim: "The local representation needs one change.".into(), + evidence: None, + next_move: None, + actions: vec![Action::Fix, Action::Stop], + }), + BackendAction::User(Action::Fix) if !self.failed_once.swap(true, Ordering::SeqCst) => { + Card::Patch(PatchCard { + id: "c_invalid".into(), + title: "Invalid first attempt".into(), + explanation: "This attempt has stale context.".into(), + warnings: vec![], + goal_complete: false, + patches: vec![FilePatch { + id: "p_1".into(), + file: "src/work.ts".into(), + diff: "@@ -1,1 +1,1 @@\n-stale\n+new\n".into(), + explanation: "Stale attempt.".into(), + }], + actions: vec![Action::Apply, Action::Retry, Action::Stop], + }) + } + BackendAction::ContractRetry(reason) => { + assert!(reason.contains("patch context was not found")); + Card::Patch(PatchCard { + id: "c_repaired".into(), + title: "Repaired local step".into(), + explanation: "Use exact current context.".into(), + warnings: vec![], + goal_complete: false, + patches: vec![FilePatch { + id: "p_1".into(), + file: "src/work.ts".into(), + diff: "@@ -1,1 +1,1 @@\n-placeholder\n+repaired\n".into(), + explanation: "Repair one line.".into(), + }], + actions: vec![Action::Apply, Action::Retry, Action::Stop], + }) + } + _ => panic!("unexpected repair backend request"), + }; + + Ok(BackendResponse { + card, + raw_output: None, + metadata: BackendMetadata { + backend: "repairing_patch".into(), + model: None, + token_usage: Some(loopbiotic_protocol::TokenUsage::estimated(10, 5)), + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "repairing_patch".into(), + streaming: false, + patches: true, + reasoning: false, + can_read_project: false, + can_use_tools: false, + } + } +} + +#[async_trait] +impl BackendAdapter for WrongTypeBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + let card = match req.action { + BackendAction::Start => Card::Hypothesis(HypothesisCard { + id: "c_1".into(), + title: "Start".into(), + claim: "Wrong type for this test.".into(), + evidence: None, + next_move: None, + actions: vec![Action::Fix, Action::Stop], + }), + _ => Card::Finding(FindingCard { + id: "c_finding".into(), + title: "Wrong type".into(), + finding: "This is deliberately not a patch.".into(), + location: None, + annotation: None, + actions: vec![Action::Fix, Action::Stop], + }), + }; + + Ok(BackendResponse { + card, + raw_output: Some("raw finding from backend".into()), + metadata: BackendMetadata { + backend: "wrong_type".into(), + model: None, + token_usage: None, + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "wrong_type".into(), + streaming: false, + patches: false, + reasoning: false, + can_read_project: false, + can_use_tools: false, + } + } +} + +#[derive(Default)] +struct CountingBackend { + inner: MockBackend, + calls: std::sync::Mutex>, +} + +impl CountingBackend { + fn record(&self, path: &str, action: &BackendAction) { + self.calls + .lock() + .unwrap() + .push(format!("{path}:{action:?}")); + } +} + +#[async_trait] +impl BackendAdapter for CountingBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + self.record("plain", &req.action); + self.inner.next_card(req).await + } + + async fn next_card_with_progress( + &self, + req: BackendRequest, + _progress: Option, + ) -> Result { + self.record("progress", &req.action); + self.inner.next_card(req).await + } + + fn capabilities(&self) -> BackendInfo { + self.inner.capabilities() + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn prefetched_fix_is_consumed_without_a_second_backend_call() { + let backend = Arc::new(CountingBackend::default()); + let mut engine = Engine::new(backend.clone()); + engine.set_prefetch_mode(PrefetchMode::Fix); + + let start = engine.start(params()).await.unwrap(); + assert!(matches!(start.card, Card::Hypothesis(_))); + + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + assert!(matches!(result.card, Card::Patch(_))); + + let calls = backend.calls.lock().unwrap().clone(); + assert_eq!(calls.len(), 2, "unexpected backend calls: {calls:?}"); + assert!(calls[0].starts_with("progress:Start")); + assert!(calls[1].starts_with("plain:User(Fix)")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stale_prefetch_is_discarded_after_context_change() { + let backend = Arc::new(CountingBackend::default()); + let mut engine = Engine::new(backend.clone()); + engine.set_prefetch_mode(PrefetchMode::Fix); + + let start = engine.start(params()).await.unwrap(); + let context = ContextBundle { + cwd: PathBuf::from("/tmp/project"), + file: PathBuf::from("src/work.ts"), + cursor: Cursor { line: 1, column: 1 }, + selection: None, + buffer_text: "const edited = true".into(), + buffer_start_line: 1, + diagnostics: vec![], + hints: vec![], + artifacts: vec![], + report: None, + }; + engine.update_context(&start.session_id, context).unwrap(); + + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + let Card::Patch(card) = result.card else { + panic!("expected patch card"); + }; + + // The mock builds the diff from the buffer's first line, so a patch + // produced from the fresh request must reference the edited buffer. + assert!( + card.patches[0].diff.contains("const edited = true"), + "patch was built from stale context: {}", + card.patches[0].diff + ); + let calls = backend.calls.lock().unwrap().clone(); + assert!( + calls + .iter() + .any(|call| call.starts_with("progress:User(Fix)")), + "real fix call missing: {calls:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fingerprint_ignores_optimizer_telemetry() { + let backend = Arc::new(CountingBackend::default()); + let mut engine = Engine::new(backend); + engine.set_prefetch_mode(PrefetchMode::Fix); + let start = engine.start(params()).await.unwrap(); + let session = engine.get(&start.session_id).unwrap(); + + let request = engine.request( + session, + BackendAction::User(Action::Fix), + session.context.clone(), + &NextState::Patch, + ); + let mut noisy = request.clone(); + noisy.context.report = Some(loopbiotic_protocol::ContextReport { + enabled: true, + cache_hits: 42, + ..Default::default() + }); + + assert_eq!(request_fingerprint(&request), request_fingerprint(&noisy)); +} + +#[derive(Default)] +struct ExpectationRecorder { + inner: MockBackend, + requests: std::sync::Mutex, String)>>, +} + +#[async_trait] +impl BackendAdapter for ExpectationRecorder { + async fn next_card(&self, req: BackendRequest) -> Result { + self.requests + .lock() + .unwrap() + .push((req.card_contract.expected_kind, req.session.prompt.clone())); + self.inner.next_card(req).await + } + + fn capabilities(&self) -> BackendInfo { + self.inner.capabilities() + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn plain_auto_prompt_expects_any_kind() { + let backend = Arc::new(ExpectationRecorder::default()); + let mut engine = Engine::new(backend.clone()); + + let mut auto = params(); + auto.mode = Mode::Auto; + engine.start(auto).await.unwrap(); + + let requests = backend.requests.lock().unwrap().clone(); + assert_eq!(requests[0].0, None); + assert_eq!(requests[0].1, "payload is empty"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn kind_prefix_forces_the_expected_card() { + let backend = Arc::new(ExpectationRecorder::default()); + let mut engine = Engine::new(backend.clone()); + let mut start_params = params(); + start_params.prompt = "/patch guard the payload".into(); + + engine.start(start_params).await.unwrap(); + + let requests = backend.requests.lock().unwrap().clone(); + assert_eq!(requests[0].0, Some(CardKind::Patch)); + assert_eq!(requests[0].1, "guard the payload"); +} + +#[derive(Default)] +struct NavigatingBackend { + calls: std::sync::Mutex>, +} + +#[async_trait] +impl BackendAdapter for NavigatingBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + self.calls.lock().unwrap().push(format!("{:?}", req.action)); + + let card = match req.action { + BackendAction::Start => Card::Hypothesis(HypothesisCard { + id: "c_1".into(), + title: "Wrong buffer suspected".into(), + claim: "The sizing lives in the component file.".into(), + evidence: None, + next_move: None, + actions: vec![Action::Fix, Action::Stop], + }), + BackendAction::User(Action::Fix) => { + Card::OpenLocation(loopbiotic_protocol::OpenLocationCard { + id: "c_nav".into(), + reason: "The change belongs in the component file.".into(), + location: loopbiotic_protocol::Location { + file: "src/component.ts".into(), + line: 3, + column: 1, + }, + }) + } + BackendAction::LocationGranted => { + let old_line = req.context.buffer_text.lines().next().unwrap_or_default(); + Card::Patch(PatchCard { + id: "c_patch".into(), + title: "Adjust icon size".into(), + explanation: "Sets the icon size on the component.".into(), + warnings: vec![], + goal_complete: false, + patches: vec![FilePatch { + id: "p_1".into(), + file: req.context.file.clone(), + diff: format!("@@ -1,1 +1,1 @@\n-{old_line}\n+const size = 16\n"), + explanation: "Shrinks the icon.".into(), + }], + actions: vec![ + Action::Apply, + Action::Retry, + Action::EditPrompt, + Action::Stop, + ], + }) + } + other => panic!("unexpected action {other:?}"), + }; + + Ok(BackendResponse { + card, + raw_output: None, + metadata: BackendMetadata { + backend: "navigating".into(), + model: None, + token_usage: Some(loopbiotic_protocol::TokenUsage::estimated(10, 5)), + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "navigating".into(), + streaming: false, + patches: true, + reasoning: false, + can_read_project: false, + can_use_tools: false, + } + } +} + +fn granted_context() -> ContextBundle { + ContextBundle { + cwd: PathBuf::from("/tmp/project"), + file: PathBuf::from("src/component.ts"), + cursor: Cursor { line: 3, column: 1 }, + selection: None, + buffer_text: "const size = 24".into(), + buffer_start_line: 1, + diagnostics: vec![], + hints: vec![], + artifacts: vec![], + report: None, + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn open_location_grant_continues_the_same_turn() { + let backend = Arc::new(NavigatingBackend::default()); + let granted: Arc>> = Arc::default(); + let granted_log = granted.clone(); + let mut engine = Engine::new(backend.clone()); + engine.set_location_granter(Arc::new(move |request, _session| { + granted_log + .lock() + .unwrap() + .push(request.location.file.display().to_string()); + Box::pin(async move { Some(granted_context()) }) + })); + + let start = engine.start(params()).await.unwrap(); + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + + let Card::Patch(card) = result.card else { + panic!("expected patch card, got {:?}", result.card); + }; + assert_eq!(card.patches[0].file, PathBuf::from("src/component.ts")); + assert!(card.patches[0].diff.contains("const size = 24")); + assert_eq!(granted.lock().unwrap().as_slice(), ["src/component.ts"]); + assert_eq!( + engine.get(&start.session_id).unwrap().context.file, + PathBuf::from("src/component.ts") + ); + + let calls = backend.calls.lock().unwrap().clone(); + assert!(calls.iter().any(|call| call.contains("LocationGranted"))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn declined_open_location_surfaces_a_deny_card() { + let backend = Arc::new(NavigatingBackend::default()); + let mut engine = Engine::new(backend); + engine.set_location_granter(Arc::new(|_, _| Box::pin(async { None }))); + + let start = engine.start(params()).await.unwrap(); + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + + let Card::Deny(card) = result.card else { + panic!("expected deny card, got {:?}", result.card); + }; + assert_eq!( + card.location.as_ref().map(|l| l.file.clone()), + Some(PathBuf::from("src/component.ts")) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn open_location_without_granter_becomes_a_deny_card() { + let backend = Arc::new(NavigatingBackend::default()); + let mut engine = Engine::new(backend); + + let start = engine.start(params()).await.unwrap(); + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + + assert!(matches!(result.card, Card::Deny(_))); +} diff --git a/rust/crates/loopbiotic_harness/src/engine/turn.rs b/rust/crates/loopbiotic_harness/src/engine/turn.rs new file mode 100644 index 0000000..1d1938b --- /dev/null +++ b/rust/crates/loopbiotic_harness/src/engine/turn.rs @@ -0,0 +1,524 @@ +//! The retry loop for a single model turn: deduplication against session +//! memory, contract validation, automatic repair retries, and conversion of +//! failures into typed error cards. + +use anyhow::{Result, anyhow}; +use loopbiotic_backends::{BackendAction, BackendProgress, BackendResponse, ProgressReporter}; +use loopbiotic_patch::{PatchCoherence, PatchNormalizer, PatchValidator}; +use loopbiotic_protocol::{ + Action, AgentAttempt, Card, ContextBundle, ErrorCard, MAX_GOAL_CHANGED_LINES, + MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, TokenUsage, +}; + +use crate::session::Session; +use crate::state::NextState; + +use super::goal::rejected_card; +use super::observations::{activate_observation, core_observation, normalize_observation}; +use super::validate::{context_targets, validate_backend_card, validate_one_card}; +use super::{Engine, MAX_LOCATION_GRANTS}; + +impl Engine { + pub(super) async fn next_distinct_response( + &self, + session: &mut Session, + action: BackendAction, + context: ContextBundle, + expected: &NextState, + progress: Option, + mut prefetched: Option, + ) -> BackendResponse { + let mut action = action; + let mut context = context; + let mut token_usage = None; + let mut attempts = Vec::new(); + let mut attempt = 0; + let mut grants = 0; + + while attempt < 3 { + let attempt_response = match prefetched.take() { + // A matching speculative response was computed for this exact + // request while the user was reading the previous card; it + // still goes through every dedup/validation gate below. + Some(response) => Ok(response), + None => { + let request = self.request(session, action, context.clone(), expected); + self.backend + .next_card_with_progress(request, progress.clone()) + .await + } + }; + let mut response = match attempt_response { + Ok(response) => response, + Err(error) => { + let detail = format!("{error:#}"); + let mut response = backend_failure_response(session, error); + merge_usage(&mut token_usage, &response.metadata.token_usage); + response.metadata.token_usage = token_usage; + attempts.push(AgentAttempt { + number: attempt + 1, + backend: response.metadata.backend.clone(), + outcome: "backend_error".into(), + token_usage: TokenUsage::default(), + detail: Some(detail), + candidate_card: None, + activities: vec![], + }); + response.metadata.attempts = attempts; + return response; + } + }; + let attempt_usage = response.metadata.token_usage.clone().unwrap_or_default(); + merge_usage(&mut token_usage, &response.metadata.token_usage); + + // A mid-turn permission request: the agent needs another file open + // before it can produce the real card. Ask the editor; on grant the + // same turn continues with the freshly captured context. Grants do + // not consume retry attempts. + if let Card::OpenLocation(request) = &response.card { + let request = request.clone(); + + if grants < MAX_LOCATION_GRANTS + && let Some(granter) = &self.location_granter + { + if let Some(progress) = &progress { + progress(BackendProgress { + session_id: session.id.clone(), + phase: "permission".into(), + message: format!( + "Agent asks to open {}", + request.location.file.display() + ), + }); + } + + if let Some(granted) = granter(request.clone(), session.id.clone()).await { + attempts.push(agent_attempt( + attempt + 1, + &response, + "location_granted", + Some(request.location.file.display().to_string()), + attempt_usage, + false, + )); + session.context = granted.clone(); + context = granted; + action = BackendAction::LocationGranted; + grants += 1; + continue; + } + } + + attempts.push(agent_attempt( + attempt + 1, + &response, + "location_declined", + Some(request.location.file.display().to_string()), + attempt_usage, + false, + )); + response.card = Card::Deny(loopbiotic_protocol::DenyCard { + id: session.next_card_id("deny"), + title: "Agent needs another file".into(), + reason: request.reason, + location: Some(request.location), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }); + response.metadata.token_usage = token_usage; + response.metadata.attempts = attempts; + return response; + } + + if !matches!(expected, NextState::GoalWhy) + && let Some((key, reason)) = duplicate_observation(session, &response.card) + { + activate_observation(session, &key); + attempts.push(agent_attempt( + attempt + 1, + &response, + if attempt < 2 { + "duplicate_retry" + } else { + "rejected" + }, + Some(reason.clone()), + attempt_usage, + true, + )); + if attempt < 2 { + if let Some(progress) = &progress { + progress(BackendProgress { + session_id: session.id.clone(), + phase: "deduplicating".into(), + message: "Retaining repeated context and requesting a distinct step" + .into(), + }); + } + action = BackendAction::ContractRetry(format!( + "{reason}. Return a distinct next observation; do not repeat known findings or signals." + )); + attempt += 1; + continue; + } + + let mut rejected = duplicate_failure_response(session, reason); + rejected.metadata.token_usage = token_usage; + rejected.metadata.attempts = attempts; + return rejected; + } + + if let Some(reason) = duplicate_completed_step(session, &response.card) { + attempts.push(agent_attempt( + attempt + 1, + &response, + if attempt < 2 { + "duplicate_step_retry" + } else { + "rejected" + }, + Some(reason.clone()), + attempt_usage, + true, + )); + if attempt < 2 { + if let Some(progress) = &progress { + progress(BackendProgress { + session_id: session.id.clone(), + phase: "deduplicating".into(), + message: "Rejecting a repeated patch step".into(), + }); + } + action = BackendAction::ContractRetry(format!( + "{reason}. Draft a materially different unresolved requirement. Do not merely rename, extract, or rephrase the accepted step. If this location is already resolved, return open_location for the actual next target or deny instead of inventing another patch here." + )); + attempt += 1; + continue; + } + + let mut rejected = duplicate_failure_response(session, reason); + rejected.metadata.token_usage = token_usage; + rejected.metadata.attempts = attempts; + return rejected; + } + + let mut candidate = response.card.clone(); + let validation = if matches!(expected, NextState::GoalLoop) { + self.normalize_goal_batch(&mut candidate, &context, &session.id) + .await + } else { + PatchNormalizer::normalize_card(&mut candidate, &context) + .and_then(|()| validate_backend_card(&candidate, expected, &context)) + } + .map(|()| PatchCoherence::annotate(&mut candidate)); + if let Err(error) = validation { + let detail = error.to_string(); + attempts.push(agent_attempt( + attempt + 1, + &response, + if attempt < 2 { + "contract_retry" + } else { + "rejected" + }, + Some(detail.clone()), + attempt_usage, + true, + )); + if attempt < 2 { + if let Some(progress) = &progress { + progress(BackendProgress { + session_id: session.id.clone(), + phase: "repairing".into(), + message: "Patch contract failed; Codex is repairing the local step" + .into(), + }); + } + let instruction = if matches!(expected, NextState::GoalLoop) { + "Re-read every affected file with read-only tools and return the corrected complete multi-file batch. Context/remove lines must be exact and contiguous in each corresponding source. Do not split the goal into another model turn; use open_location only if a required source cannot be inspected." + } else { + "Rebuild the same step. Source context/remove lines must be exact and contiguous in the supplied buffer; added lines do not replace omitted source context. The resulting local step must remain type-correct without work deferred to a later card. If the change belongs in a different file than the supplied buffer, return an open_location op with that place instead of another patch." + }; + action = BackendAction::ContractRetry(format!( + "The previous card failed the local patch contract: {detail}. {instruction}" + )); + attempt += 1; + continue; + } + + response.card = + rejected_card(session, &candidate, error, response.raw_output.as_deref()); + response.metadata.token_usage = token_usage; + response.metadata.attempts = attempts; + return response; + } + + response.card = candidate; + attempts.push(agent_attempt( + attempt + 1, + &response, + "accepted", + None, + attempt_usage, + false, + )); + response.metadata.token_usage = token_usage; + response.metadata.attempts = attempts; + return response; + } + + // Every branch of the final (third) attempt above returns, so this + // should not run. The invariant is subtle, though — the location-grant + // branch continues without incrementing `attempt` — so if a future + // edit ever falls through, degrade to the same error card the + // backend-failure path produces instead of panicking the daemon + // mid-request. + let mut response = backend_failure_response( + session, + anyhow!("the agent produced no acceptable card after {attempt} attempts"), + ); + response.metadata.token_usage = token_usage; + response.metadata.attempts = attempts; + response + } + + async fn normalize_goal_batch( + &self, + candidate: &mut Card, + current: &ContextBundle, + session_id: &str, + ) -> Result<()> { + if !matches!(candidate, Card::Patch(_)) { + return validate_backend_card(candidate, &NextState::GoalLoop, current); + } + + validate_one_card(candidate)?; + // Correct miscounted hunk headers before the count check rejects them. + PatchNormalizer::normalize_hunk_headers(candidate)?; + PatchValidator::validate_card_with_limits( + candidate, + MAX_GOAL_PATCH_FILES, + MAX_GOAL_HUNKS_PER_PATCH, + MAX_GOAL_CHANGED_LINES, + )?; + let Card::Patch(card) = candidate else { + // Guarded by the `matches!` early return above; degrade to a + // contract failure (retry/rejected card) instead of panicking if + // that guard ever drifts. + return Err(anyhow!("goal batch candidate is no longer a patch card")); + }; + + for index in 0..card.patches.len() { + let file = card.patches[index].file.clone(); + let source = if let Some(provider) = &self.source_context_provider { + provider(file.clone(), session_id.to_string()).await + } else if context_targets(current, &file) { + Some(current.clone()) + } else { + None + } + .ok_or_else(|| anyhow!("editor source is unavailable for {}", file.display()))?; + + if !context_targets(&source, &file) { + return Err(anyhow!( + "editor returned {} while validating {}", + source.file.display(), + file.display() + )); + } + + let mut single = Card::Patch(loopbiotic_protocol::PatchCard { + id: card.id.clone(), + title: card.title.clone(), + explanation: card.explanation.clone(), + warnings: vec![], + goal_complete: card.goal_complete, + patches: vec![card.patches[index].clone()], + actions: card.actions.clone(), + }); + PatchNormalizer::normalize_card(&mut single, &source) + .map_err(|error| anyhow!("{}: {error}", file.display()))?; + PatchValidator::validate_card_against_context(&single, &source) + .map_err(|error| anyhow!("{}: {error}", file.display()))?; + + let Card::Patch(single) = single else { + // Constructed as a patch card just above and normalization + // never changes the card kind; degrade instead of panicking. + return Err(anyhow!( + "{}: patch normalization changed the card kind", + file.display() + )); + }; + card.patches[index] = single.patches.into_iter().next().ok_or_else(|| { + anyhow!("{}: patch normalization dropped the hunk", file.display()) + })?; + } + + NextState::GoalLoop.validate(candidate) + } +} + +fn agent_attempt( + number: usize, + response: &BackendResponse, + outcome: &str, + detail: Option, + token_usage: TokenUsage, + include_candidate: bool, +) -> AgentAttempt { + AgentAttempt { + number, + backend: response.metadata.backend.clone(), + outcome: outcome.into(), + token_usage, + detail, + candidate_card: include_candidate.then(|| response.card.clone()), + activities: response.metadata.activities.clone(), + } +} + +fn merge_usage( + total: &mut Option, + turn: &Option, +) { + let Some(turn) = turn else { + return; + }; + + if let Some(total) = total { + total.add(turn); + } else { + *total = Some(turn.clone()); + } +} + +fn duplicate_observation(session: &Session, card: &Card) -> Option<(String, String)> { + let (key, _, _) = core_observation(card)?; + session + .observation_index + .contains_key(&key) + .then(|| (key, "backend repeated a retained observation".into())) +} + +fn duplicate_completed_step(session: &Session, card: &Card) -> Option { + let Card::Patch(card) = card else { + return None; + }; + + card.patches.iter().find_map(|patch| { + let candidate = normalize_step(&patch.explanation); + session + .completed_step_signatures + .iter() + .filter(|(file, _)| file == &patch.file) + .find(|(_, completed)| step_similarity(completed, &candidate) >= 0.72) + .map(|_| { + format!( + "backend proposed a patch semantically overlapping an accepted step in {}", + patch.file.display() + ) + }) + }) +} + +pub(super) fn normalize_step(text: &str) -> String { + const STOP_WORDS: &[&str] = &[ + "add", "adds", "and", "dla", "dodaje", "do", "for", "from", "into", "oraz", "the", "this", + "that", "to", "use", "uses", "with", "zmienia", + ]; + + normalize_observation(text) + .split_whitespace() + .filter(|word| word.chars().count() > 2 && !STOP_WORDS.contains(word)) + .collect::>() + .join(" ") +} + +fn step_similarity(left: &str, right: &str) -> f32 { + let left = left + .split_whitespace() + .collect::>(); + let right = right + .split_whitespace() + .collect::>(); + if left.is_empty() || right.is_empty() { + return 0.0; + } + + let shared = left.intersection(&right).count() as f32; + shared / left.len().min(right.len()) as f32 +} + +fn backend_failure_response(session: &Session, error: anyhow::Error) -> BackendResponse { + BackendResponse { + card: Card::Error(ErrorCard { + id: session.next_card_id("backend_error"), + title: "Backend request failed".into(), + message: format!("{error:#}"), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }), + raw_output: None, + metadata: loopbiotic_backends::BackendMetadata { + backend: "harness".into(), + model: None, + token_usage: None, + activities: vec![], + attempts: vec![], + }, + } +} + +fn duplicate_failure_response(session: &Session, reason: String) -> BackendResponse { + BackendResponse { + card: Card::Error(ErrorCard { + id: session.next_card_id("duplicate_error"), + title: "Backend repeated retained context".into(), + message: format!( + "{reason}. The duplicate was retained in session memory but was not shown again." + ), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }), + raw_output: None, + metadata: loopbiotic_backends::BackendMetadata { + backend: "harness".into(), + model: None, + token_usage: None, + activities: vec![], + attempts: vec![], + }, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use loopbiotic_protocol::{FilePatch, PatchCard}; + + use super::super::tests::params; + use super::*; + + #[test] + fn detects_rephrased_completed_patch_in_the_same_file() { + let mut session = Session::new(params()); + session.completed_step_signatures.push(( + PathBuf::from("src/work.ts"), + normalize_step("Extract payload validation into a local helper"), + )); + let card = Card::Patch(PatchCard { + id: "c_repeat".into(), + title: "Extract validation".into(), + explanation: "Keep validation local.".into(), + warnings: vec![], + goal_complete: false, + patches: vec![FilePatch { + id: "p_repeat".into(), + file: PathBuf::from("src/work.ts"), + diff: "@@ -1,1 +1,1 @@\n-old\n+new\n".into(), + explanation: "Extract the local helper used for payload validation".into(), + }], + actions: vec![Action::Apply], + }); + + assert!(duplicate_completed_step(&session, &card).is_some()); + } +} diff --git a/rust/crates/loopbiotic_harness/src/engine/validate.rs b/rust/crates/loopbiotic_harness/src/engine/validate.rs new file mode 100644 index 0000000..540b3a3 --- /dev/null +++ b/rust/crates/loopbiotic_harness/src/engine/validate.rs @@ -0,0 +1,290 @@ +//! Structural validation of backend cards, patch targets, and editor apply +//! results before they are accepted into a session. + +use anyhow::{Result, anyhow}; +use loopbiotic_patch::PatchValidator; +use loopbiotic_protocol::{ + Card, ContextBundle, MAX_GOAL_CHANGED_LINES, MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, + PatchApplyResult, +}; + +use crate::session::Session; +use crate::state::NextState; + +pub(super) fn validate_backend_card( + card: &Card, + next_state: &NextState, + context: &ContextBundle, +) -> Result<()> { + // Backend errors must reach the editor unchanged instead of being replaced by + // a generic state-machine error such as "expected patch card". + if matches!(card, Card::Error(_)) { + return Ok(()); + } + + // A denial is valid in any state: the agent is telling the user it cannot + // produce the expected card, so only the card text itself is checked. + if matches!(card, Card::Deny(_)) { + return validate_one_card(card); + } + + validate_one_card(card)?; + if matches!(next_state, NextState::GoalLoop) { + PatchValidator::validate_card_with_limits( + card, + MAX_GOAL_PATCH_FILES, + MAX_GOAL_HUNKS_PER_PATCH, + MAX_GOAL_CHANGED_LINES, + )?; + } else { + PatchValidator::validate_card(card)?; + } + validate_patch_target(card, context)?; + PatchValidator::validate_card_against_context(card, context)?; + next_state.validate(card)?; + + Ok(()) +} + +fn validate_patch_target(card: &Card, context: &ContextBundle) -> Result<()> { + let Card::Patch(card) = card else { + return Ok(()); + }; + let expected = if context.file.is_absolute() { + context + .file + .strip_prefix(&context.cwd) + .unwrap_or(&context.file) + } else { + &context.file + }; + + if let Some(patch) = card.patches.first() + && patch.file != expected + { + return Err(anyhow!( + "patch targets {}, but the accepted source location is {}; open that location before Fix", + patch.file.display(), + expected.display() + )); + } + + Ok(()) +} + +pub(super) fn context_targets(context: &ContextBundle, file: &std::path::Path) -> bool { + let actual = if context.file.is_absolute() { + context + .file + .strip_prefix(&context.cwd) + .unwrap_or(&context.file) + } else { + &context.file + }; + + actual == file +} + +pub(super) fn validate_one_card(card: &Card) -> Result<()> { + if card.id().trim().is_empty() { + return Err(anyhow!("card id is empty")); + } + + match card { + Card::Hypothesis(card) => { + require_text("card title", &card.title)?; + require_text("hypothesis claim", &card.claim)?; + if let Some(location) = &card.evidence { + validate_location( + &location.file, + location.line, + location.column, + "hypothesis evidence", + )?; + } + if let Some(loopbiotic_protocol::NextMove::OpenLocation(location)) = &card.next_move { + validate_location( + &location.file, + location.line, + location.column, + "hypothesis next move", + )?; + } + } + Card::Finding(card) => { + require_text("card title", &card.title)?; + require_text("finding", &card.finding)?; + if let Some(location) = &card.location { + validate_location( + &location.file, + location.line, + location.column, + "finding location", + )?; + } + } + Card::Patch(card) => { + require_text("card title", &card.title)?; + require_text("patch explanation", &card.explanation)?; + for patch in &card.patches { + require_text("file patch explanation", &patch.explanation)?; + } + } + Card::Choice(card) => { + require_text("card title", &card.title)?; + require_text("choice question", &card.question)?; + if card.options.is_empty() { + return Err(anyhow!("choice card has no options")); + } + for option in &card.options { + require_text("choice option id", &option.id)?; + require_text("choice option label", &option.label)?; + } + } + Card::Deny(card) => { + require_text("card title", &card.title)?; + require_text("deny reason", &card.reason)?; + if let Some(location) = &card.location { + validate_location( + &location.file, + location.line, + location.column, + "deny location", + )?; + } + } + Card::OpenLocation(card) => { + require_text("open_location reason", &card.reason)?; + validate_location( + &card.location.file, + card.location.line, + card.location.column, + "open_location", + )?; + } + Card::Summary(card) => { + require_text("card title", &card.title)?; + require_text("summary", &card.summary)?; + } + Card::Error(card) => { + require_text("card title", &card.title)?; + require_text("error message", &card.message)?; + } + } + + if !matches!(card, Card::Choice(_) | Card::Summary(_)) && card.actions().is_empty() { + return Err(anyhow!("card has no actions")); + } + + Ok(()) +} + +fn require_text(field: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(anyhow!("{field} is empty")); + } + + Ok(()) +} + +fn validate_location( + file: &std::path::Path, + line: usize, + column: usize, + label: &str, +) -> Result<()> { + if file.as_os_str().is_empty() { + return Err(anyhow!("{label} file is empty")); + } + if line == 0 || column == 0 { + return Err(anyhow!("{label} line and column must start at 1")); + } + + Ok(()) +} + +pub(super) fn validate_apply_result(session: &Session, result: &PatchApplyResult) -> Result<()> { + let Some(Card::Patch(card)) = session.cards.last() else { + return Err(anyhow!("patch state has no current patch card")); + }; + + if result.card_id != card.id { + return Err(anyhow!( + "apply result targets card {}, but current patch card is {}", + result.card_id, + card.id + )); + } + + let expected_patch_ids = card + .patches + .iter() + .map(|patch| patch.id.clone()) + .collect::>(); + if result.patch_ids != expected_patch_ids { + return Err(anyhow!( + "apply result patch ids do not match the current patch card" + )); + } + + let expected_files = card + .patches + .iter() + .map(|patch| patch.file.clone()) + .collect::>(); + if result.accepted && result.changed_files != expected_files { + return Err(anyhow!( + "accepted apply result changed files do not match the current patch card" + )); + } + if !result.accepted && !result.changed_files.is_empty() { + return Err(anyhow!( + "rejected apply result cannot contain changed files" + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use loopbiotic_protocol::{Action, FindingCard}; + + use super::*; + + #[test] + fn rejects_card_with_invalid_location_coordinates() { + let card = Card::Finding(FindingCard { + id: "c_bad_location".into(), + title: "Target".into(), + finding: "The target is here.".into(), + location: Some(loopbiotic_protocol::Location { + file: "src/main.rs".into(), + line: 0, + column: 1, + }), + annotation: None, + actions: vec![Action::Open, Action::Stop], + }); + + let error = validate_one_card(&card).unwrap_err(); + + assert!(error.to_string().contains("must start at 1")); + } + + #[test] + fn rejects_card_with_empty_semantic_body() { + let card = Card::Finding(FindingCard { + id: "c_empty".into(), + title: "Target".into(), + finding: " ".into(), + location: None, + annotation: None, + actions: vec![Action::Stop], + }); + + let error = validate_one_card(&card).unwrap_err(); + + assert!(error.to_string().contains("finding is empty")); + } +} From fe82b4e0722dad10fa10d378337a92c6b24cee9e Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:38:33 +0200 Subject: [PATCH 15/37] test: cover session state transition tables --- rust/crates/loopbiotic_harness/src/state.rs | 286 ++++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/rust/crates/loopbiotic_harness/src/state.rs b/rust/crates/loopbiotic_harness/src/state.rs index d74de1a..b7b3c02 100644 --- a/rust/crates/loopbiotic_harness/src/state.rs +++ b/rust/crates/loopbiotic_harness/src/state.rs @@ -98,3 +98,289 @@ impl NextState { } } } + +#[cfg(test)] +mod tests { + use loopbiotic_protocol::{ + ChoiceCard, DenyCard, ErrorCard, FindingCard, HypothesisCard, Location, OpenLocationCard, + PatchCard, SummaryCard, + }; + + use super::*; + + fn apply_patch() -> Action { + Action::ApplyPatch { + patch_id: "p_1".into(), + } + } + + fn all_actions() -> Vec { + vec![ + Action::Follow, + Action::Why, + Action::ResumeDraft, + Action::Fix, + Action::OtherLead, + Action::Apply, + apply_patch(), + Action::Retry, + Action::EditPrompt, + Action::Open, + Action::RunCheck, + Action::Next, + Action::Stop, + ] + } + + #[test] + fn every_legal_transition_yields_its_next_state() { + use Action as A; + use NextState as N; + use SessionState as S; + + let table: Vec<(S, A, N)> = vec![ + (S::CardShown, A::Follow, N::Card), + (S::CardShown, A::Why, N::Card), + (S::CardShown, A::OtherLead, N::Card), + (S::CardShown, A::Open, N::Card), + (S::CardShown, A::Retry, N::Any), + (S::CardShown, A::EditPrompt, N::Any), + (S::CardShown, A::Fix, N::Patch), + (S::CardShown, A::Stop, N::Finished), + (S::PatchShown, A::Apply, N::Summary), + (S::PatchShown, apply_patch(), N::Summary), + (S::PatchShown, A::Why, N::GoalWhy), + (S::PatchShown, A::Retry, N::Patch), + (S::PatchShown, A::EditPrompt, N::Patch), + (S::PatchShown, A::Stop, N::Finished), + (S::PatchFailed, A::Retry, N::Patch), + (S::PatchFailed, A::EditPrompt, N::Patch), + (S::PatchFailed, A::Stop, N::Finished), + (S::PatchExplained, A::ResumeDraft, N::Patch), + (S::PatchExplained, A::Why, N::GoalWhy), + (S::PatchExplained, A::Retry, N::GoalWhy), + (S::PatchExplained, A::EditPrompt, N::GoalWhy), + (S::PatchExplained, A::Stop, N::Finished), + (S::GoalLoopFailed, A::Retry, N::GoalLoop), + (S::GoalLoopFailed, A::EditPrompt, N::GoalLoop), + (S::GoalLoopFailed, A::Stop, N::Finished), + (S::Summary, A::Next, N::GoalLoop), + (S::Summary, A::RunCheck, N::Card), + (S::Summary, A::Stop, N::Finished), + ]; + + for (state, action, expected) in table { + let next = state + .next(&action) + .unwrap_or_else(|error| panic!("{state:?} + {action:?} should be legal: {error}")); + assert_eq!(next, expected, "{state:?} + {action:?}"); + } + } + + #[test] + fn illegal_transitions_are_rejected() { + use Action as A; + use SessionState as S; + + let table: Vec<(S, A)> = vec![ + (S::Idle, A::Follow), + (S::Idle, A::Stop), + (S::Thinking, A::Fix), + (S::Thinking, A::Stop), + (S::CardShown, A::Apply), + (S::CardShown, apply_patch()), + (S::CardShown, A::ResumeDraft), + (S::CardShown, A::Next), + (S::CardShown, A::RunCheck), + (S::PatchShown, A::Follow), + (S::PatchShown, A::Fix), + (S::PatchShown, A::OtherLead), + (S::PatchShown, A::Next), + (S::PatchFailed, A::Apply), + (S::PatchFailed, A::Fix), + (S::PatchFailed, A::Why), + (S::PatchExplained, A::Apply), + (S::PatchExplained, A::Fix), + (S::GoalLoopFailed, A::Apply), + (S::GoalLoopFailed, A::Fix), + (S::GoalLoopFailed, A::Next), + (S::Summary, A::Apply), + (S::Summary, A::Fix), + (S::Summary, A::Retry), + (S::Applying, A::Stop), + (S::Applied, A::Stop), + (S::Checking, A::Stop), + ]; + + for (state, action) in table { + let error = state.next(&action).unwrap_err(); + assert!( + error.to_string().contains("invalid action"), + "{state:?} + {action:?}: {error}" + ); + } + } + + #[test] + fn finished_rejects_every_action() { + for action in all_actions() { + let error = SessionState::Finished.next(&action).unwrap_err(); + assert!( + error.to_string().contains("session is finished"), + "{action:?}: {error}" + ); + } + } + + fn hypothesis_card() -> Card { + Card::Hypothesis(HypothesisCard { + id: "c_h".into(), + title: "t".into(), + claim: "c".into(), + evidence: None, + next_move: None, + actions: vec![Action::Stop], + }) + } + + fn finding_card() -> Card { + Card::Finding(FindingCard { + id: "c_f".into(), + title: "t".into(), + finding: "f".into(), + location: None, + annotation: None, + actions: vec![Action::Stop], + }) + } + + fn patch_card() -> Card { + Card::Patch(PatchCard { + id: "c_p".into(), + title: "t".into(), + explanation: "e".into(), + warnings: vec![], + goal_complete: false, + patches: vec![], + actions: vec![Action::Apply], + }) + } + + fn choice_card() -> Card { + Card::Choice(ChoiceCard { + id: "c_c".into(), + title: "t".into(), + question: "q".into(), + options: vec![], + }) + } + + fn deny_card() -> Card { + Card::Deny(DenyCard { + id: "c_d".into(), + title: "t".into(), + reason: "r".into(), + location: None, + actions: vec![Action::Stop], + }) + } + + fn open_location_card() -> Card { + Card::OpenLocation(OpenLocationCard { + id: "c_o".into(), + reason: "r".into(), + location: Location { + file: "src/work.ts".into(), + line: 1, + column: 1, + }, + }) + } + + fn summary_card() -> Card { + Card::Summary(SummaryCard { + id: "c_s".into(), + title: "t".into(), + summary: "s".into(), + changed_files: vec![], + next_actions: vec![], + }) + } + + fn error_card() -> Card { + Card::Error(ErrorCard { + id: "c_e".into(), + title: "t".into(), + message: "m".into(), + actions: vec![Action::Stop], + }) + } + + #[test] + fn next_state_validates_expected_card_kinds() { + use NextState as N; + + let table: Vec<(N, Card, std::result::Result<(), &str>)> = vec![ + (N::Any, patch_card(), Ok(())), + (N::Any, summary_card(), Ok(())), + (N::Any, error_card(), Ok(())), + (N::Card, hypothesis_card(), Ok(())), + (N::Card, finding_card(), Ok(())), + (N::Card, choice_card(), Ok(())), + (N::Card, deny_card(), Ok(())), + (N::Card, open_location_card(), Ok(())), + (N::Card, error_card(), Ok(())), + (N::Card, patch_card(), Err("patch card is not allowed here")), + ( + N::Card, + summary_card(), + Err("summary card is not allowed here"), + ), + (N::Patch, patch_card(), Ok(())), + (N::Patch, finding_card(), Err("expected patch card")), + (N::Patch, summary_card(), Err("expected patch card")), + (N::GoalLoop, patch_card(), Ok(())), + (N::GoalLoop, summary_card(), Ok(())), + (N::GoalLoop, choice_card(), Ok(())), + ( + N::GoalLoop, + finding_card(), + Err("expected the next goal patch"), + ), + ( + N::GoalLoop, + hypothesis_card(), + Err("expected the next goal patch"), + ), + (N::GoalWhy, finding_card(), Ok(())), + ( + N::GoalWhy, + patch_card(), + Err("expected an explanation of the pending patch"), + ), + (N::Summary, summary_card(), Ok(())), + (N::Summary, patch_card(), Err("expected summary card")), + (N::Finished, summary_card(), Ok(())), + (N::Finished, finding_card(), Err("expected final summary")), + ]; + + for (next_state, card, expected) in table { + let result = next_state.validate(&card); + match expected { + Ok(()) => assert!( + result.is_ok(), + "{next_state:?} should accept {:?}: {result:?}", + card.kind() + ), + Err(message) => match result { + Ok(()) => panic!("{next_state:?} should reject {:?}", card.kind()), + Err(error) => assert!( + error.to_string().contains(message), + "{next_state:?} + {:?}: {error}", + card.kind() + ), + }, + } + } + } +} From a7ff06148503ca6a6406487aeeb3494393e59d36 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:39:09 +0200 Subject: [PATCH 16/37] fix: time out wedged backend turns, dedupe backend helpers into support --- .../loopbiotic_backends/src/claude_app.rs | 229 +++++++++-------- .../crates/loopbiotic_backends/src/generic.rs | 141 +++++----- rust/crates/loopbiotic_backends/src/lib.rs | 1 + rust/crates/loopbiotic_backends/src/ollama.rs | 39 +-- .../loopbiotic_backends/src/stdio_agent.rs | 190 +++++++------- .../crates/loopbiotic_backends/src/support.rs | 242 ++++++++++++++++++ 6 files changed, 540 insertions(+), 302 deletions(-) create mode 100644 rust/crates/loopbiotic_backends/src/support.rs diff --git a/rust/crates/loopbiotic_backends/src/claude_app.rs b/rust/crates/loopbiotic_backends/src/claude_app.rs index 413fd4a..0a74a00 100644 --- a/rust/crates/loopbiotic_backends/src/claude_app.rs +++ b/rust/crates/loopbiotic_backends/src/claude_app.rs @@ -1,19 +1,23 @@ use std::collections::HashMap; -use std::hash::{DefaultHasher, Hash, Hasher}; use std::process::Stdio; use std::sync::Arc; +use std::time::Duration; use anyhow::{Result, anyhow}; use async_trait::async_trait; -use loopbiotic_protocol::{Action, BackendInfo, Card, ErrorCard, TokenUsage}; +use loopbiotic_protocol::{BackendInfo, Card, TokenUsage}; use serde_json::{Value, json}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::Mutex; +use crate::support::{ + Phase, TurnTimedOut, action_value, args_from_env, await_turn, context_fingerprint, error_card, + optional_env, report_progress, turn_phase, turn_timeout_from_env, +}; use crate::{ - BackendAction, BackendAdapter, BackendMetadata, BackendProgress, BackendRequest, - BackendResponse, ProgressReporter, enforce_card_contract, estimate_tokens, + BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, ProgressReporter, + enforce_card_contract, estimate_tokens, }; const SYSTEM_PROMPT: &str = r#"You are a local Loopbiotic pair-programming partner inside the user's editor. @@ -51,15 +55,10 @@ pub struct ClaudeAppBackend { model: Option, discovery_model: Option, discovery_thinking: Option, + turn_timeout: Option, state: Mutex, } -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -enum Phase { - Discovery, - Patch, -} - #[derive(Default)] struct ClaudeAppState { session_key: Option, @@ -77,6 +76,19 @@ struct ClaudeSlot { reported_model: Option, } +impl ClaudeSlot { + /// Kills a wedged CLI and forgets its conversation so the next turn + /// spawns a fresh process with full context. + fn kill_process(&mut self) { + if let Some(process) = self.process.as_mut() { + let _ = process.child.start_kill(); + } + self.process = None; + self.context_fingerprint = None; + self.reported_model = None; + } +} + struct ClaudeAppProcess { child: Child, stdin: ChildStdin, @@ -89,6 +101,7 @@ impl Drop for ClaudeAppProcess { } } +#[derive(Debug)] struct TurnOutput { text: String, token_usage: Option, @@ -206,7 +219,7 @@ impl ClaudeAppBackend { pub fn from_env() -> Result { let command = std::env::var("LOOPBIOTIC_CLAUDE_COMMAND").unwrap_or_else(|_| "claude".into()); - let args = args_from_env("LOOPBIOTIC_CLAUDE_ARGS_JSON", "LOOPBIOTIC_CLAUDE_ARGS")?; + let args = args_from_env("LOOPBIOTIC_CLAUDE_ARGS_JSON", "LOOPBIOTIC_CLAUDE_ARGS", "")?; let model = optional_env("LOOPBIOTIC_CLAUDE_MODEL"); let discovery_model = optional_env("LOOPBIOTIC_CLAUDE_DISCOVERY_MODEL"); let discovery_thinking = optional_env("LOOPBIOTIC_CLAUDE_DISCOVERY_THINKING"); @@ -226,6 +239,26 @@ impl ClaudeAppBackend { model: Option, discovery_model: Option, discovery_thinking: Option, + ) -> Self { + Self::with_turn_timeout( + command, + args, + model, + discovery_model, + discovery_thinking, + turn_timeout_from_env(), + ) + } + + /// Internal constructor that fixes the per-turn deadline instead of + /// reading it from the environment; tests use it to avoid env races. + pub(crate) fn with_turn_timeout( + command: impl Into, + args: Vec, + model: Option, + discovery_model: Option, + discovery_thinking: Option, + turn_timeout: Option, ) -> Self { Self { command: command.into(), @@ -233,6 +266,7 @@ impl ClaudeAppBackend { model, discovery_model, discovery_thinking, + turn_timeout, state: Mutex::new(ClaudeAppState::default()), } } @@ -397,6 +431,42 @@ impl ClaudeAppBackend { let slot = self.slot(&req.session.id, phase).await; let mut slot = slot.lock().await; + self.guarded_turn(&mut slot, req, phase, progress).await + } + + /// Runs one turn under the per-turn deadline. On expiry the wedged CLI is + /// killed and its slot cleared so the next turn spawns a fresh process. + async fn guarded_turn( + &self, + slot: &mut ClaudeSlot, + req: &BackendRequest, + phase: Phase, + progress: Option<&ProgressReporter>, + ) -> Result { + let result = await_turn( + "Claude", + self.turn_timeout, + self.run_turn(slot, req, phase, progress), + ) + .await; + + if result + .as_ref() + .is_err_and(|error| error.is::()) + { + slot.kill_process(); + } + + result + } + + async fn run_turn( + &self, + slot: &mut ClaudeSlot, + req: &BackendRequest, + phase: Phase, + progress: Option<&ProgressReporter>, + ) -> Result { report_progress( progress, &req.session.id, @@ -417,12 +487,12 @@ impl ClaudeAppBackend { let include_context = slot.context_fingerprint != Some(fingerprint); slot.context_fingerprint = Some(fingerprint); - if let Err(error) = send_turn(&mut slot, &turn_prompt(req, include_context)).await { + if let Err(error) = send_turn(slot, &turn_prompt(req, include_context)).await { // The process may have died between turns; retry once on a fresh // process with full context before giving up. slot.process = Some(self.spawn_process(&slot.model, &self.phase_thinking(phase))?); slot.context_fingerprint = Some(fingerprint); - send_turn(&mut slot, &turn_prompt(req, true)) + send_turn(slot, &turn_prompt(req, true)) .await .map_err(|retry| anyhow!("could not reach claude: {error}; retry: {retry}"))?; } @@ -489,12 +559,7 @@ impl ClaudeAppBackend { } fn error_card(message: impl Into) -> Card { - Card::Error(ErrorCard { - id: "c_claude_error".into(), - title: "Claude error".into(), - message: message.into(), - actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], - }) + error_card("c_claude_error", "Claude error", message) } } @@ -571,16 +636,6 @@ impl BackendAdapter for ClaudeAppBackend { } } -fn turn_phase(req: &BackendRequest) -> Phase { - if req.card_contract.expected_kind == Some(loopbiotic_protocol::CardKind::Patch) - || req.card_contract.allow_goal_completion - { - Phase::Patch - } else { - Phase::Discovery - } -} - fn turn_prompt(req: &BackendRequest, include_context: bool) -> String { let mut value = json!({ "s": { @@ -613,40 +668,6 @@ fn turn_prompt(req: &BackendRequest, include_context: bool) -> String { serde_json::to_string(&value).unwrap_or_default() } -fn context_fingerprint(req: &BackendRequest) -> u64 { - let mut hasher = DefaultHasher::new(); - req.context.file.hash(&mut hasher); - req.context.cursor.line.hash(&mut hasher); - req.context.cursor.column.hash(&mut hasher); - req.context.buffer_start_line.hash(&mut hasher); - req.context.buffer_text.hash(&mut hasher); - for diagnostic in &req.context.diagnostics { - diagnostic.file.hash(&mut hasher); - diagnostic.line.hash(&mut hasher); - diagnostic.message.hash(&mut hasher); - } - for artifact in &req.context.artifacts { - artifact.file.hash(&mut hasher); - artifact.start_line.hash(&mut hasher); - artifact.text.hash(&mut hasher); - } - hasher.finish() -} - -fn action_value(action: &BackendAction) -> Value { - match action { - BackendAction::Start => json!({"kind": "start"}), - BackendAction::User(action) => { - json!({"kind": "user", "action": serde_json::to_value(action).unwrap_or_default()}) - } - BackendAction::Reply(text) => json!({"kind": "reply", "text": text}), - BackendAction::ContractRetry(reason) => { - json!({"kind": "contract_retry", "reason": reason}) - } - BackendAction::LocationGranted => json!({"kind": "location_granted"}), - } -} - fn parse_stream_event(value: &Value) -> StreamEvent { match value.get("type").and_then(Value::as_str) { Some("system") if value.get("subtype").and_then(Value::as_str) == Some("init") => { @@ -745,41 +766,6 @@ fn parse_usage(value: Option<&Value>) -> Option { }) } -fn optional_env(name: &str) -> Option { - std::env::var(name) - .ok() - .filter(|value| !value.trim().is_empty()) -} - -fn args_from_env(json_name: &str, plain_name: &str) -> Result> { - if let Ok(value) = std::env::var(json_name) - && !value.trim().is_empty() - { - return Ok(serde_json::from_str(&value)?); - } - - Ok(std::env::var(plain_name) - .unwrap_or_default() - .split_whitespace() - .map(str::to_string) - .collect()) -} - -fn report_progress( - progress: Option<&ProgressReporter>, - session_id: &str, - phase: &str, - message: &str, -) { - if let Some(progress) = progress { - progress(BackendProgress { - session_id: session_id.into(), - phase: phase.into(), - message: message.into(), - }); - } -} - #[cfg(test)] mod tests { use super::*; @@ -916,17 +902,46 @@ mod tests { assert_eq!(extract_string_field(r#"{"titl"#, "title"), None); } - #[test] - fn routes_patch_turns_to_the_patch_phase() { - let mut req = crate::test_request(); - assert!(matches!(turn_phase(&req), Phase::Discovery)); + #[tokio::test] + async fn wedged_claude_process_times_out_and_clears_the_slot() { + // A `sleep` child stands in for a wedged CLI: it accepts the turn on + // stdin but never writes a stream event to stdout. + let mut child = Command::new("sleep") + .arg("30") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut slot = ClaudeSlot { + process: Some(ClaudeAppProcess { + child, + stdin, + stdout: BufReader::new(stdout).lines(), + }), + ..ClaudeSlot::default() + }; + let backend = ClaudeAppBackend::with_turn_timeout( + "claude-unused", + vec![], + None, + None, + None, + Some(Duration::from_millis(100)), + ); - req.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Patch); - assert!(matches!(turn_phase(&req), Phase::Patch)); + let error = backend + .guarded_turn(&mut slot, &crate::test_request(), Phase::Discovery, None) + .await + .unwrap_err(); - req.card_contract.expected_kind = None; - req.card_contract.allow_goal_completion = true; - assert!(matches!(turn_phase(&req), Phase::Patch)); + assert!(error.is::(), "unexpected error: {error}"); + assert!( + slot.process.is_none(), + "timed-out process must be cleared so the next turn spawns fresh" + ); } #[test] diff --git a/rust/crates/loopbiotic_backends/src/generic.rs b/rust/crates/loopbiotic_backends/src/generic.rs index a73cb2a..84aec8c 100644 --- a/rust/crates/loopbiotic_backends/src/generic.rs +++ b/rust/crates/loopbiotic_backends/src/generic.rs @@ -1,35 +1,57 @@ +use std::time::Duration; + use anyhow::{Result, anyhow}; use async_trait::async_trait; -use loopbiotic_protocol::{Action, AgentOp, BackendInfo, Card, ErrorCard, TokenUsage}; +use loopbiotic_protocol::{AgentOp, BackendInfo, Card, TokenUsage}; use serde_json::json; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; +use crate::support::{ + TurnTimedOut, action_value, args_from_env, await_turn, error_card, report_progress, + turn_timeout_from_env, +}; use crate::{ - BackendAdapter, BackendMetadata, BackendProgress, BackendRequest, BackendResponse, - LoopbioticStreamEvent, ProgressReporter, enforce_card_contract, estimate_tokens, - parse_loopbiotic_stream_event, result_text, + BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, LoopbioticStreamEvent, + ProgressReporter, enforce_card_contract, estimate_tokens, parse_loopbiotic_stream_event, + result_text, }; pub struct GenericCliBackend { command: String, args: Vec, + turn_timeout: Option, } impl GenericCliBackend { pub fn new(command: impl Into, args: Vec) -> Self { + Self::with_turn_timeout(command, args, turn_timeout_from_env()) + } + + /// Internal constructor that fixes the per-turn deadline instead of + /// reading it from the environment; tests use it to avoid env races. + pub(crate) fn with_turn_timeout( + command: impl Into, + args: Vec, + turn_timeout: Option, + ) -> Self { Self { command: command.into(), args, + turn_timeout, } } pub fn from_env() -> Result { let command = std::env::var("LOOPBIOTIC_GENERIC_COMMAND") .map_err(|_| anyhow!("LOOPBIOTIC_GENERIC_COMMAND is required"))?; - let args = args_from_env("LOOPBIOTIC_GENERIC_ARGS_JSON", "LOOPBIOTIC_GENERIC_ARGS")?; + let args = args_from_env( + "LOOPBIOTIC_GENERIC_ARGS_JSON", + "LOOPBIOTIC_GENERIC_ARGS", + "", + )?; - Ok(Self { command, args }) + Ok(Self::new(command, args)) } fn prompt(&self, req: &BackendRequest) -> String { @@ -37,12 +59,7 @@ impl GenericCliBackend { } fn error_card(message: impl Into) -> Card { - Card::Error(ErrorCard { - id: "c_backend_error".into(), - title: "Backend error".into(), - message: message.into(), - actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], - }) + error_card("c_backend_error", "Backend error", message) } } @@ -93,34 +110,6 @@ pub(crate) fn generic_prompt(req: &BackendRequest) -> String { serde_json::to_string(&value).unwrap_or_default() } -fn action_value(action: &crate::BackendAction) -> serde_json::Value { - match action { - crate::BackendAction::Start => json!({"kind": "start"}), - crate::BackendAction::User(action) => { - json!({"kind": "user", "action": serde_json::to_value(action).unwrap_or_default()}) - } - crate::BackendAction::Reply(text) => json!({"kind": "reply", "text": text}), - crate::BackendAction::ContractRetry(reason) => { - json!({"kind": "contract_retry", "reason": reason}) - } - crate::BackendAction::LocationGranted => json!({"kind": "location_granted"}), - } -} - -fn args_from_env(json_name: &str, plain_name: &str) -> Result> { - if let Ok(value) = std::env::var(json_name) - && !value.trim().is_empty() - { - return Ok(serde_json::from_str(&value)?); - } - - Ok(std::env::var(plain_name) - .unwrap_or_default() - .split_whitespace() - .map(str::to_string) - .collect()) -} - #[async_trait] impl BackendAdapter for GenericCliBackend { async fn next_card(&self, req: BackendRequest) -> Result { @@ -180,17 +169,31 @@ impl BackendAdapter for GenericCliBackend { &format!("Sending the task to {backend_name}"), ); - while let Some(line) = stdout.next_line().await? { - match parse_loopbiotic_stream_event(&line) { - Some(LoopbioticStreamEvent::Progress { phase, message }) => { - report_progress(progress.as_ref(), &req.session.id, &phase, &message); + let stream = async { + while let Some(line) = stdout.next_line().await? { + match parse_loopbiotic_stream_event(&line) { + Some(LoopbioticStreamEvent::Progress { phase, message }) => { + report_progress(progress.as_ref(), &req.session.id, &phase, &message); + } + Some(LoopbioticStreamEvent::Result(result)) => output.push(result_text(result)), + None => output.push(line), } - Some(LoopbioticStreamEvent::Result(result)) => output.push(result_text(result)), - None => output.push(line), } - } - child.wait().await?; + child.wait().await?; + + Ok(()) + }; + let stream_result = await_turn("The backend CLI", self.turn_timeout, stream).await; + if stream_result + .as_ref() + .is_err_and(|error| error.is::()) + { + // A one-shot process: kill the wedged CLI and surface the timeout + // as a normal backend error. + let _ = child.start_kill(); + } + stream_result?; let stderr = stderr_task.await??; let stdout = output.join("\n"); let raw_output = format!("{stdout}{stderr}"); @@ -227,21 +230,6 @@ impl BackendAdapter for GenericCliBackend { } } -fn report_progress( - progress: Option<&ProgressReporter>, - session_id: &str, - phase: &str, - message: &str, -) { - if let Some(progress) = progress { - progress(BackendProgress { - session_id: session_id.into(), - phase: phase.into(), - message: message.into(), - }); - } -} - fn backend_name(command: &str) -> String { std::path::Path::new(command) .file_name() @@ -302,13 +290,7 @@ fn excerpt(output: &str) -> String { return "Raw output was empty.".into(); } - let mut text = output.chars().take(800).collect::(); - - if output.chars().count() > 800 { - text.push_str("\n..."); - } - - format!("Raw output:\n{text}") + format!("Raw output:\n{}", crate::excerpt(output, 800)) } fn first_json_object(output: &str) -> Option<&str> { @@ -420,10 +402,21 @@ mod tests { assert!(matches!(card, Card::Hypothesis(_))); } - #[test] - fn serializes_user_action_as_protocol_value() { - let value = action_value(&crate::BackendAction::User(Action::Fix)); + #[tokio::test] + async fn wedged_cli_times_out_and_is_killed() { + // `sleep` swallows the prompt on stdin and never answers: a stand-in + // for a CLI stuck on an auth prompt or deadlock. + let backend = GenericCliBackend::with_turn_timeout( + "sleep", + vec!["30".into()], + Some(std::time::Duration::from_millis(100)), + ); + + let error = backend.next_card(crate::test_request()).await.unwrap_err(); - assert_eq!(value["action"], "fix"); + assert!( + error.is::(), + "unexpected error: {error}" + ); } } diff --git a/rust/crates/loopbiotic_backends/src/lib.rs b/rust/crates/loopbiotic_backends/src/lib.rs index ee583d3..d67b5b1 100644 --- a/rust/crates/loopbiotic_backends/src/lib.rs +++ b/rust/crates/loopbiotic_backends/src/lib.rs @@ -5,6 +5,7 @@ pub mod mock; pub mod ollama; pub mod stdio_agent; pub mod stream; +pub(crate) mod support; use std::sync::Arc; diff --git a/rust/crates/loopbiotic_backends/src/ollama.rs b/rust/crates/loopbiotic_backends/src/ollama.rs index ee3fbf5..e3be0d3 100644 --- a/rust/crates/loopbiotic_backends/src/ollama.rs +++ b/rust/crates/loopbiotic_backends/src/ollama.rs @@ -1,12 +1,13 @@ use anyhow::{Result, anyhow}; use async_trait::async_trait; -use loopbiotic_protocol::{Action, BackendInfo, Card, ErrorCard, TokenUsage}; +use loopbiotic_protocol::{BackendInfo, Card, TokenUsage}; use serde::Deserialize; use serde_json::json; +use crate::support::{error_card, optional_env, report_progress}; use crate::{ - BackendAdapter, BackendMetadata, BackendProgress, BackendRequest, BackendResponse, - ProgressReporter, enforce_card_contract, estimate_tokens, + BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, ProgressReporter, + enforce_card_contract, estimate_tokens, }; /// Talks to a local Ollama server over its HTTP API instead of spawning @@ -37,14 +38,10 @@ impl OllamaBackend { pub fn from_env() -> Result { let model = std::env::var("LOOPBIOTIC_OLLAMA_MODEL") .map_err(|_| anyhow!("LOOPBIOTIC_OLLAMA_MODEL is required"))?; - let host = std::env::var("LOOPBIOTIC_OLLAMA_HOST") - .ok() - .filter(|value| !value.trim().is_empty()) + let host = optional_env("LOOPBIOTIC_OLLAMA_HOST") .unwrap_or_else(|| "http://127.0.0.1:11434".into()); - let keep_alive = std::env::var("LOOPBIOTIC_OLLAMA_KEEP_ALIVE") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "30m".into()); + let keep_alive = + optional_env("LOOPBIOTIC_OLLAMA_KEEP_ALIVE").unwrap_or_else(|| "30m".into()); Ok(Self::new(host, model, keep_alive)) } @@ -89,12 +86,7 @@ impl OllamaBackend { } fn error_card(message: impl Into) -> Card { - Card::Error(ErrorCard { - id: "c_ollama_error".into(), - title: "Ollama error".into(), - message: message.into(), - actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], - }) + error_card("c_ollama_error", "Ollama error", message) } } @@ -153,18 +145,3 @@ impl BackendAdapter for OllamaBackend { } } } - -fn report_progress( - progress: Option<&ProgressReporter>, - session_id: &str, - phase: &str, - message: &str, -) { - if let Some(progress) = progress { - progress(BackendProgress { - session_id: session_id.into(), - phase: phase.into(), - message: message.into(), - }); - } -} diff --git a/rust/crates/loopbiotic_backends/src/stdio_agent.rs b/rust/crates/loopbiotic_backends/src/stdio_agent.rs index 6d3a6af..1adadab 100644 --- a/rust/crates/loopbiotic_backends/src/stdio_agent.rs +++ b/rust/crates/loopbiotic_backends/src/stdio_agent.rs @@ -1,22 +1,28 @@ use std::process::Stdio; +use std::time::Duration; use anyhow::{Result, anyhow}; use async_trait::async_trait; -use loopbiotic_protocol::{Action, AgentOp, BackendInfo, Card, ErrorCard, TokenUsage}; +use loopbiotic_protocol::{AgentOp, BackendInfo, Card, TokenUsage}; use serde_json::json; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::Mutex; +use crate::support::{ + TurnTimedOut, action_value, args_from_env, await_turn, error_card, report_progress, + turn_timeout_from_env, +}; use crate::{ - BackendAction, BackendAdapter, BackendMetadata, BackendProgress, BackendRequest, - BackendResponse, LoopbioticStreamEvent, ProgressReporter, enforce_card_contract, - estimate_tokens, parse_loopbiotic_stream_event, result_text, + BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, LoopbioticStreamEvent, + ProgressReporter, enforce_card_contract, estimate_tokens, parse_loopbiotic_stream_event, + result_text, }; pub struct StdioAgentBackend { command: String, args: Vec, + turn_timeout: Option, process: Mutex>, } @@ -30,15 +36,26 @@ impl StdioAgentBackend { pub fn from_env() -> Result { let command = std::env::var("LOOPBIOTIC_AGENT_COMMAND") .map_err(|_| anyhow!("LOOPBIOTIC_AGENT_COMMAND is required"))?; - let args = args_from_env("LOOPBIOTIC_AGENT_ARGS_JSON", "LOOPBIOTIC_AGENT_ARGS")?; + let args = args_from_env("LOOPBIOTIC_AGENT_ARGS_JSON", "LOOPBIOTIC_AGENT_ARGS", "")?; Ok(Self::new(command, args)) } pub fn new(command: impl Into, args: Vec) -> Self { + Self::with_turn_timeout(command, args, turn_timeout_from_env()) + } + + /// Internal constructor that fixes the per-turn deadline instead of + /// reading it from the environment; tests use it to avoid env races. + pub(crate) fn with_turn_timeout( + command: impl Into, + args: Vec, + turn_timeout: Option, + ) -> Self { Self { command: command.into(), args, + turn_timeout, process: Mutex::new(None), } } @@ -88,52 +105,75 @@ impl StdioAgentBackend { ); self.ensure().await?; - let mut process = self.process.lock().await; - let process = process + let mut guard = self.process.lock().await; + let process = guard .as_mut() .ok_or_else(|| anyhow!("agent process unavailable"))?; - let event = agent_event(req); - let line = serde_json::to_string(&event)?; - let input_tokens = estimate_tokens(&line); - - process.stdin.write_all(line.as_bytes()).await?; - process.stdin.write_all(b"\n").await?; - process.stdin.flush().await?; - - report_progress( - progress, - &req.session.id, - "working", - "Agent is processing the request", - ); - loop { - let Some(line) = process.stdout.next_line().await? else { - return Err(anyhow!("agent closed stdout")); - }; - - match parse_loopbiotic_stream_event(&line) { - Some(LoopbioticStreamEvent::Progress { phase, message }) => { - report_progress(progress, &req.session.id, &phase, &message); - } - Some(LoopbioticStreamEvent::Result(result)) => { - return Ok(AgentAnswer { - line: result_text(result), - input_tokens, - }); - } - None => return Ok(AgentAnswer { line, input_tokens }), + let result = await_turn( + "The agent", + self.turn_timeout, + exchange(process, req, progress), + ) + .await; + + if result + .as_ref() + .is_err_and(|error| error.is::()) + { + // Kill the wedged agent and forget it so the next turn respawns. + if let Some(process) = guard.as_mut() { + let _ = process.child.start_kill(); } + *guard = None; } + + result } fn error_card(message: impl Into) -> Card { - Card::Error(ErrorCard { - id: "c_agent_error".into(), - title: "Agent error".into(), - message: message.into(), - actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], - }) + error_card("c_agent_error", "Agent error", message) + } +} + +/// Sends one turn to the agent and reads its stream until the result line. +async fn exchange( + process: &mut AgentProcess, + req: &BackendRequest, + progress: Option<&ProgressReporter>, +) -> Result { + let event = agent_event(req); + let line = serde_json::to_string(&event)?; + let input_tokens = estimate_tokens(&line); + + process.stdin.write_all(line.as_bytes()).await?; + process.stdin.write_all(b"\n").await?; + process.stdin.flush().await?; + + report_progress( + progress, + &req.session.id, + "working", + "Agent is processing the request", + ); + + loop { + let Some(line) = process.stdout.next_line().await? else { + return Err(anyhow!("agent closed stdout")); + }; + + match parse_loopbiotic_stream_event(&line) { + Some(LoopbioticStreamEvent::Progress { phase, message }) => { + report_progress(progress, &req.session.id, &phase, &message); + } + Some(LoopbioticStreamEvent::Result(result)) => { + return Ok(AgentAnswer { + line: result_text(result), + input_tokens, + }); + } + None => return Ok(AgentAnswer { line, input_tokens }), + } } } @@ -186,21 +226,7 @@ impl BackendAdapter for StdioAgentBackend { } } -fn report_progress( - progress: Option<&ProgressReporter>, - session_id: &str, - phase: &str, - message: &str, -) { - if let Some(progress) = progress { - progress(BackendProgress { - session_id: session_id.into(), - phase: phase.into(), - message: message.into(), - }); - } -} - +#[derive(Debug)] struct AgentAnswer { line: String, input_tokens: usize, @@ -225,40 +251,12 @@ fn agent_event(req: &BackendRequest) -> serde_json::Value { }) } -fn action_value(action: &BackendAction) -> serde_json::Value { - match action { - BackendAction::Start => json!({"kind": "start"}), - BackendAction::User(action) => { - json!({"kind": "user", "action": serde_json::to_value(action).unwrap_or_default()}) - } - BackendAction::Reply(text) => json!({"kind": "reply", "text": text}), - BackendAction::ContractRetry(reason) => { - json!({"kind": "contract_retry", "reason": reason}) - } - BackendAction::LocationGranted => json!({"kind": "location_granted"}), - } -} - fn agent_api() -> serde_json::Value { json!( "Return one JSON Loopbiotic op only. Ops: hypothesis, finding, patch, choice, deny, open_location, summary, error. Use deny(title,reason) when you cannot or should not proceed, such as an ambiguous prompt or missing information; the reason is shown to the user. error is only for technical failures. Behave as an equal pair-programming partner. Return patch for user action fix or start mode fix unless impossible. When limits.allow_goal_completion is true, inspect every required file and return the complete multi-file patch batch in one turn within the supplied file, hunk, and changed-line limits; Loopbiotic verifies and reviews its hunks locally. Set patch.goal_complete true when accepting the batch finishes the original goal. When goal completion is true and the expected card is finding because the user asked why, explain the pending hunk without replacing it or advancing the goal. A non-goal patch is one local step: exactly one file and one hunk within the supplied changed-line limit. patch.diff must be unified diff hunks starting with @@. You may first emit newline-delimited {\"t\":\"loopbiotic_progress\",\"phase\":string,\"message\":string} records with concise user-visible activity summaries. Never emit hidden reasoning or private chain-of-thought. End with either a raw Loopbiotic op or {\"t\":\"loopbiotic_result\",\"result\":}." ) } -fn args_from_env(json_name: &str, plain_name: &str) -> Result> { - if let Ok(value) = std::env::var(json_name) - && !value.trim().is_empty() - { - return Ok(serde_json::from_str(&value)?); - } - - Ok(std::env::var(plain_name) - .unwrap_or_default() - .split_whitespace() - .map(str::to_string) - .collect()) -} - fn parse_agent_output(output: &str) -> Result { let op = serde_json::from_str::(output.trim())?; @@ -276,10 +274,22 @@ mod tests { assert!(matches!(card, Card::Hypothesis(_))); } - #[test] - fn serializes_user_action_as_protocol_value() { - let value = action_value(&BackendAction::User(Action::Fix)); + #[tokio::test] + async fn wedged_agent_times_out_and_respawns_next_turn() { + // `sleep` swallows the event on stdin and never answers: a stand-in + // for an agent stuck on an auth prompt or deadlock. + let backend = StdioAgentBackend::with_turn_timeout( + "sleep", + vec!["30".into()], + Some(Duration::from_millis(100)), + ); - assert_eq!(value["action"], "fix"); + let error = backend.ask(&crate::test_request(), None).await.unwrap_err(); + + assert!(error.is::(), "unexpected error: {error}"); + assert!( + backend.process.lock().await.is_none(), + "timed-out process must be cleared so the next turn respawns" + ); } } diff --git a/rust/crates/loopbiotic_backends/src/support.rs b/rust/crates/loopbiotic_backends/src/support.rs new file mode 100644 index 0000000..2a630e0 --- /dev/null +++ b/rust/crates/loopbiotic_backends/src/support.rs @@ -0,0 +1,242 @@ +//! Helpers shared by the backend adapters: turn deadlines, env parsing, and +//! the request/progress plumbing that every adapter repeats. + +use std::future::Future; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::time::Duration; + +use anyhow::Result; +use loopbiotic_protocol::{Action, Card, ErrorCard}; +use serde_json::{Value, json}; + +use crate::{BackendAction, BackendProgress, BackendRequest, ProgressReporter}; + +pub(crate) const TURN_TIMEOUT_ENV: &str = "LOOPBIOTIC_TURN_TIMEOUT_SECS"; +const DEFAULT_TURN_TIMEOUT: Duration = Duration::from_secs(600); + +/// Reads the per-turn deadline once at backend construction. `None` means the +/// deadline is disabled (`LOOPBIOTIC_TURN_TIMEOUT_SECS=0`). +pub(crate) fn turn_timeout_from_env() -> Option { + parse_turn_timeout(std::env::var(TURN_TIMEOUT_ENV).ok().as_deref()) +} + +/// LLM turns are long; the deadline is a wedge-breaker, not latency control, +/// so the default is generous and unparseable values fall back to it rather +/// than silently removing the protection. +pub(crate) fn parse_turn_timeout(raw: Option<&str>) -> Option { + match raw.map(str::trim).map(str::parse::) { + Some(Ok(0)) => None, + Some(Ok(secs)) => Some(Duration::from_secs(secs)), + Some(Err(_)) | None => Some(DEFAULT_TURN_TIMEOUT), + } +} + +/// Marker error for an expired turn deadline. Callers detect it with +/// `error.is::()` to kill/invalidate their cached process and to +/// skip in-turn retries (a wedged CLI would only wedge again). +#[derive(Debug)] +pub(crate) struct TurnTimedOut { + backend: &'static str, + limit: Duration, +} + +impl std::fmt::Display for TurnTimedOut { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} produced no result within {:?}; the process was killed and the next turn starts fresh. Set {TURN_TIMEOUT_ENV} to adjust the deadline (0 disables it).", + self.backend, self.limit + ) + } +} + +impl std::error::Error for TurnTimedOut {} + +/// Awaits one whole backend turn under the optional deadline. On expiry the +/// turn future is dropped and a [`TurnTimedOut`] error is returned; the caller +/// is responsible for killing its child process and invalidating any cached +/// session state so the next turn spawns fresh. +pub(crate) async fn await_turn( + backend: &'static str, + limit: Option, + turn: impl Future>, +) -> Result { + let Some(limit) = limit else { + return turn.await; + }; + + match tokio::time::timeout(limit, turn).await { + Ok(result) => result, + Err(_) => Err(anyhow::Error::new(TurnTimedOut { backend, limit })), + } +} + +/// The two process lanes the app-server backends keep per session: discovery +/// turns must not block behind a running (possibly speculative) patch turn. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum Phase { + Discovery, + Patch, +} + +pub(crate) fn turn_phase(req: &BackendRequest) -> Phase { + if req.card_contract.expected_kind == Some(loopbiotic_protocol::CardKind::Patch) + || req.card_contract.allow_goal_completion + { + Phase::Patch + } else { + Phase::Discovery + } +} + +/// Hash of everything the model sees about the source context; used to skip +/// re-sending an unchanged buffer to a persistent process. +pub(crate) fn context_fingerprint(req: &BackendRequest) -> u64 { + let mut hasher = DefaultHasher::new(); + req.context.file.hash(&mut hasher); + req.context.cursor.line.hash(&mut hasher); + req.context.cursor.column.hash(&mut hasher); + req.context.buffer_start_line.hash(&mut hasher); + req.context.buffer_text.hash(&mut hasher); + for diagnostic in &req.context.diagnostics { + diagnostic.file.hash(&mut hasher); + diagnostic.line.hash(&mut hasher); + diagnostic.message.hash(&mut hasher); + } + for artifact in &req.context.artifacts { + artifact.file.hash(&mut hasher); + artifact.start_line.hash(&mut hasher); + artifact.text.hash(&mut hasher); + } + hasher.finish() +} + +pub(crate) fn action_value(action: &BackendAction) -> Value { + match action { + BackendAction::Start => json!({"kind": "start"}), + BackendAction::User(action) => { + // Action is a plain protocol enum whose derived Serialize emits + // strings/objects only; it cannot fail, and sending a silent null + // here would corrupt the turn. + let action = + serde_json::to_value(action).expect("protocol Action serialization is infallible"); + json!({"kind": "user", "action": action}) + } + BackendAction::Reply(text) => json!({"kind": "reply", "text": text}), + BackendAction::ContractRetry(reason) => { + json!({"kind": "contract_retry", "reason": reason}) + } + BackendAction::LocationGranted => json!({"kind": "location_granted"}), + } +} + +pub(crate) fn report_progress( + progress: Option<&ProgressReporter>, + session_id: &str, + phase: &str, + message: &str, +) { + if let Some(progress) = progress { + progress(BackendProgress { + session_id: session_id.into(), + phase: phase.into(), + message: message.into(), + }); + } +} + +pub(crate) fn error_card(id: &str, title: &str, message: impl Into) -> Card { + Card::Error(ErrorCard { + id: id.into(), + title: title.into(), + message: message.into(), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }) +} + +/// Reads backend args from `json_name` (a JSON array) or `plain_name` +/// (whitespace-separated), falling back to `default_args`. +pub(crate) fn args_from_env( + json_name: &str, + plain_name: &str, + default_args: &str, +) -> Result> { + if let Ok(value) = std::env::var(json_name) + && !value.trim().is_empty() + { + return Ok(serde_json::from_str(&value)?); + } + + Ok(std::env::var(plain_name) + .unwrap_or_else(|_| default_args.to_string()) + .split_whitespace() + .map(str::to_string) + .collect()) +} + +pub(crate) fn optional_env(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_user_action_as_protocol_value() { + let value = action_value(&BackendAction::User(Action::Fix)); + + assert_eq!(value["action"], "fix"); + } + + #[test] + fn routes_patch_turns_to_the_patch_phase() { + let mut req = crate::test_request(); + assert_eq!(turn_phase(&req), Phase::Discovery); + + req.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Patch); + assert_eq!(turn_phase(&req), Phase::Patch); + + req.card_contract.expected_kind = None; + req.card_contract.allow_goal_completion = true; + assert_eq!(turn_phase(&req), Phase::Patch); + } + + #[test] + fn turn_timeout_defaults_and_supports_disabling() { + assert_eq!(parse_turn_timeout(None), Some(DEFAULT_TURN_TIMEOUT)); + assert_eq!(parse_turn_timeout(Some("")), Some(DEFAULT_TURN_TIMEOUT)); + assert_eq!( + parse_turn_timeout(Some("not a number")), + Some(DEFAULT_TURN_TIMEOUT) + ); + assert_eq!( + parse_turn_timeout(Some(" 45 ")), + Some(Duration::from_secs(45)) + ); + assert_eq!(parse_turn_timeout(Some("0")), None); + } + + #[tokio::test] + async fn await_turn_reports_a_timed_out_turn() { + let result: Result<()> = await_turn( + "Test backend", + Some(Duration::from_millis(10)), + std::future::pending(), + ) + .await; + + let error = result.unwrap_err(); + assert!(error.is::()); + assert!(error.to_string().contains(TURN_TIMEOUT_ENV)); + } + + #[tokio::test] + async fn await_turn_without_deadline_runs_to_completion() { + let result = await_turn("Test backend", None, async { Ok(7) }).await; + + assert_eq!(result.unwrap(), 7); + } +} From 484458ba2d5c0dd1bbcf41a0210877294f30368f Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 11:39:09 +0200 Subject: [PATCH 17/37] refactor: split codex_app into transport, parse, and schema modules --- .../loopbiotic_backends/src/codex_app.rs | 1726 ----------------- .../loopbiotic_backends/src/codex_app/mod.rs | 683 +++++++ .../src/codex_app/parse.rs | 268 +++ .../src/codex_app/schema.rs | 340 ++++ .../src/codex_app/transport.rs | 469 +++++ 5 files changed, 1760 insertions(+), 1726 deletions(-) delete mode 100644 rust/crates/loopbiotic_backends/src/codex_app.rs create mode 100644 rust/crates/loopbiotic_backends/src/codex_app/mod.rs create mode 100644 rust/crates/loopbiotic_backends/src/codex_app/parse.rs create mode 100644 rust/crates/loopbiotic_backends/src/codex_app/schema.rs create mode 100644 rust/crates/loopbiotic_backends/src/codex_app/transport.rs diff --git a/rust/crates/loopbiotic_backends/src/codex_app.rs b/rust/crates/loopbiotic_backends/src/codex_app.rs deleted file mode 100644 index c340811..0000000 --- a/rust/crates/loopbiotic_backends/src/codex_app.rs +++ /dev/null @@ -1,1726 +0,0 @@ -use std::collections::HashMap; -use std::hash::{DefaultHasher, Hash, Hasher}; -use std::process::Stdio; -use std::sync::Arc; - -use anyhow::{Result, anyhow}; -use async_trait::async_trait; -use loopbiotic_protocol::{Action, AgentOp, BackendInfo, Card, ErrorCard, TokenUsage}; -use serde::Deserialize; -use serde_json::{Value, json}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; -use tokio::process::{Child, ChildStdin, ChildStdout, Command}; -use tokio::sync::Mutex; - -use crate::{ - BackendAction, BackendAdapter, BackendMetadata, BackendProgress, BackendRequest, - BackendResponse, ProgressReporter, enforce_card_contract, estimate_tokens, -}; - -/// Keeps discovery and patch work on independent app-server processes. The -/// split lets a speculative patch run while the user continues discovery, -/// matching the phase-isolated process model used by the Claude adapter. -pub struct CodexAppBackend { - command: String, - args: Vec, - model: Option, - effort: Option, - discovery: Arc>, - patch: Arc>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Phase { - Discovery, - Patch, -} - -struct CodexAppState { - process: Option, - next_id: u64, - threads: HashMap, - context_fingerprints: HashMap, -} - -struct CodexAppProcess { - child: Child, - stdin: ChildStdin, - stdout: Lines>, -} - -struct TurnOutput { - text: String, - token_usage: Option, - activities: Vec, -} - -#[derive(Deserialize)] -struct StructuredPatchOp { - op: String, - title: String, - explanation: String, - #[serde(default)] - goal_complete: bool, - patches: Vec, -} - -#[derive(Deserialize)] -struct StructuredFilePatch { - id: Option, - file: std::path::PathBuf, - explanation: String, - hunks: Vec, -} - -#[derive(Deserialize)] -struct StructuredHunk { - old_start: usize, - new_start: usize, - lines: Vec, -} - -#[derive(Deserialize)] -struct StructuredLine { - kind: StructuredLineKind, - text: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "snake_case")] -enum StructuredLineKind { - Context, - Remove, - Add, -} - -impl CodexAppBackend { - pub fn from_env() -> Result { - let command = std::env::var("LOOPBIOTIC_CODEX_COMMAND").unwrap_or_else(|_| "codex".into()); - let args = args_from_env("LOOPBIOTIC_CODEX_ARGS_JSON", "LOOPBIOTIC_CODEX_ARGS")?; - let model = std::env::var("LOOPBIOTIC_CODEX_MODEL") - .ok() - .filter(|value| !value.trim().is_empty()); - let effort = std::env::var("LOOPBIOTIC_CODEX_EFFORT") - .ok() - .filter(|value| !value.trim().is_empty()) - .or_else(|| Some("low".into())); - - Ok(Self::new(command, args, model, effort)) - } - - pub fn new( - command: impl Into, - args: Vec, - model: Option, - effort: Option, - ) -> Self { - Self { - command: command.into(), - args, - model, - effort, - discovery: Arc::new(Mutex::new(CodexAppState::default())), - patch: Arc::new(Mutex::new(CodexAppState::default())), - } - } - - fn lane(&self, phase: Phase) -> Arc> { - match phase { - Phase::Discovery => self.discovery.clone(), - Phase::Patch => self.patch.clone(), - } - } - - async fn ensure(state: &mut CodexAppState, command: &str, args: &[String]) -> Result<()> { - if state.process.is_some() { - return Ok(()); - } - - // Threads are ephemeral to one app-server process. Never carry their - // IDs or context cache into a replacement process after a crash. - state.clear_conversation(); - - debug("starting codex app-server"); - let mut child = Command::new(command) - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .kill_on_drop(true) - .spawn()?; - - let stdin = child - .stdin - .take() - .ok_or_else(|| anyhow!("codex app-server stdin unavailable"))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| anyhow!("codex app-server stdout unavailable"))?; - - state.process = Some(CodexAppProcess { - child, - stdin, - stdout: BufReader::new(stdout).lines(), - }); - - if let Err(error) = state - .request(json!({ - "method": "initialize", - "params": { - "clientInfo": { - "name": "loopbiotic", - "version": env!("CARGO_PKG_VERSION") - }, - "capabilities": { - "experimentalApi": true - } - } - })) - .await - { - state.invalidate_process(); - return Err(error); - } - debug("codex app-server initialized"); - - Ok(()) - } - - async fn thread_id( - state: &mut CodexAppState, - req: &BackendRequest, - model: &Option, - ) -> Result { - let patch_turn = turn_phase(req) == Phase::Patch; - let goal_loop = req.card_contract.allow_goal_completion; - let thread_key = thread_key(req); - - if let Some(thread_id) = state.threads.get(&thread_key) { - return Ok(thread_id.clone()); - } - - let base_instructions = if goal_loop { - "You are a local Loopbiotic coding agent executing one persistent goal. You may use targeted read-only project tools to inspect the repository and choose the next edit. Never edit files yourself. Return exactly one final JSON object matching the supplied output schema and no prose." - } else if patch_turn { - "You are a local Loopbiotic pair-programming partner. Do not use tools, commands, file reads, or repo inspection. Never edit files. Return exactly one final JSON object matching the supplied output schema and no prose." - } else { - "You are a local Loopbiotic pair-programming partner. You may use at most two targeted read-only project tool calls to find the next relevant code block. Stop searching once the supplied context supports an exact location. Never edit files. Return exactly one final JSON object matching the supplied output schema and no prose." - }; - let developer_instructions = if goal_loop { - "Drive the original goal from start to finish. In one work turn inspect every required file and return the complete multi-file patch batch; Loopbiotic reviews its hunks locally. When the user asks why, explain the pending hunk without advancing or replacing it. Preserve progress across turns and do not repeat accepted work." - } else if patch_turn { - "Work as an equal pair-programming partner. Propose one coherent local block at the supplied location and explain why this is the useful next move. Do not take over the whole task. Return one structured patch hunk as an editable draft, not a finished agenda." - } else { - "Work as an equal pair-programming partner. Inspect only enough code to identify one coherent next move. Explain what you noticed, why it matters, and how the code reveals it. Do not dictate line-by-line work or plan the whole task. Return one exact location so the keyboard can pass back to the user." - }; - - debug("starting codex thread"); - let response = state - .request(json!({ - "method": "thread/start", - "params": { - "cwd": req.context.cwd, - "sandbox": "read-only", - "approvalPolicy": "never", - "ephemeral": true, - "model": model, - "baseInstructions": base_instructions, - "developerInstructions": developer_instructions - } - })) - .await?; - - let thread_id = response - .get("thread") - .and_then(|thread| thread.get("id")) - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("codex app-server thread/start returned no thread id"))? - .to_string(); - - state.threads.insert(thread_key, thread_id.clone()); - debug("codex thread started"); - - Ok(thread_id) - } - - async fn ask( - &self, - req: &BackendRequest, - progress: Option<&ProgressReporter>, - ) -> Result { - report_progress(progress, &req.session.id, "starting", "Starting Codex"); - let lane = self.lane(turn_phase(req)); - let mut state = lane.lock().await; - - let first = self.ask_once(&mut state, req, progress).await; - let Err(first_error) = first else { - return first; - }; - if state.process.is_some() { - return Err(first_error); - } - - // Transport failure invalidates the whole lane. Retry once on a fresh - // app-server; invalidation cleared the old thread IDs and fingerprints, - // so this attempt necessarily sends the complete source context. - report_progress( - progress, - &req.session.id, - "restarting", - "Restarting the Codex session", - ); - self.ask_once(&mut state, req, progress) - .await - .map_err(|retry| anyhow!("codex connection failed: {first_error}; retry: {retry}")) - } - - async fn ask_once( - &self, - state: &mut CodexAppState, - req: &BackendRequest, - progress: Option<&ProgressReporter>, - ) -> Result { - Self::ensure(state, &self.command, &self.args).await?; - - let thread_id = Self::thread_id(state, req, &self.model).await?; - let fingerprint = context_fingerprint(req); - let include_context = state.context_fingerprints.get(&thread_id) != Some(&fingerprint); - state - .context_fingerprints - .insert(thread_id.clone(), fingerprint); - let input = prompt(req, include_context); - report_progress( - progress, - &req.session.id, - "requesting", - "Sending the request to Codex", - ); - debug("starting codex turn"); - let response = state - .request(json!({ - "method": "turn/start", - "params": { - "threadId": thread_id, - "cwd": req.context.cwd, - "approvalPolicy": "never", - "sandboxPolicy": { - "type": "readOnly", - "networkAccess": false - }, - "input": [{ - "type": "text", - "text": input, - "text_elements": [] - }], - "model": self.model, - "effort": self.effort, - "outputSchema": output_schema(req) - } - })) - .await?; - - let turn_id = response - .get("turn") - .and_then(|turn| turn.get("id")) - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("codex app-server turn/start returned no turn id"))?; - let turn_id = turn_id.to_string(); - debug("codex turn started"); - - report_progress( - progress, - &req.session.id, - "working", - "Codex is processing the request", - ); - state.read_turn(&turn_id, &req.session.id, progress).await - } - - async fn warm_up(&self) -> Result<()> { - let lane = self.lane(Phase::Discovery); - let mut state = lane.lock().await; - - Self::ensure(&mut state, &self.command, &self.args).await - } - - fn error_card(message: impl Into) -> Card { - Card::Error(ErrorCard { - id: "c_codex_app_error".into(), - title: "Codex app-server error".into(), - message: message.into(), - actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], - }) - } -} - -fn turn_phase(req: &BackendRequest) -> Phase { - if req.card_contract.expected_kind == Some(loopbiotic_protocol::CardKind::Patch) - || req.card_contract.allow_goal_completion - { - Phase::Patch - } else { - Phase::Discovery - } -} - -fn thread_key(req: &BackendRequest) -> String { - if req.card_contract.allow_goal_completion { - return format!("{}:goal", req.session.id); - } - let patch_turn = turn_phase(req) == Phase::Patch; - format!( - "{}:{}:{}", - req.session.id, - if patch_turn { "patch" } else { "discover" }, - if patch_turn { - req.session.completed_steps.len() - } else { - 0 - } - ) -} - -impl Drop for CodexAppProcess { - fn drop(&mut self) { - let _ = self.child.start_kill(); - } -} - -impl Default for CodexAppState { - fn default() -> Self { - Self { - process: None, - next_id: 1, - threads: HashMap::new(), - context_fingerprints: HashMap::new(), - } - } -} - -impl CodexAppState { - fn clear_conversation(&mut self) { - self.threads.clear(); - self.context_fingerprints.clear(); - } - - fn invalidate_process(&mut self) { - self.process = None; - self.clear_conversation(); - } - - fn next_request_id(&mut self) -> u64 { - let id = self.next_id; - self.next_id += 1; - id - } - - async fn request(&mut self, mut request: Value) -> Result { - let id = self.next_request_id(); - request["id"] = json!(id); - - let line = serde_json::to_string(&request)?; - self.send_line(&line).await?; - - loop { - let message = self.next_message().await?; - - if self.handle_server_request(&message).await? { - continue; - } - - if message.get("id").and_then(Value::as_u64) != Some(id) { - continue; - } - - if let Some(error) = message.get("error") { - let message = error - .get("message") - .and_then(Value::as_str) - .unwrap_or("codex app-server request failed"); - - return Err(anyhow!(message.to_string())); - } - - return Ok(message.get("result").cloned().unwrap_or(Value::Null)); - } - } - - async fn read_turn( - &mut self, - turn_id: &str, - session_id: &str, - progress: Option<&ProgressReporter>, - ) -> Result { - let mut text = String::new(); - let mut token_usage = None; - let mut activities = Vec::new(); - - loop { - let message = self.next_message().await?; - - if self.handle_server_request(&message).await? { - continue; - } - - let method = message.get("method").and_then(Value::as_str); - let params = message.get("params").unwrap_or(&Value::Null); - let message_turn_id = message_turn_id(params); - - if let Some((phase, label)) = progress_for_message(&message, turn_id) { - report_progress(progress, session_id, phase, label); - } - - if method == Some("item/completed") - && message_turn_id == Some(turn_id) - && let Some(item) = params.get("item") - && item.get("type").and_then(Value::as_str) == Some("agentMessage") - && item.get("phase").and_then(Value::as_str) == Some("final_answer") - && let Some(value) = item.get("text").and_then(Value::as_str) - { - text = value.to_string(); - } - - if method == Some("item/completed") - && message_turn_id == Some(turn_id) - && let Some(item) = params.get("item") - && let Some(activity) = activity_summary(item) - && !activities.contains(&activity) - { - activities.push(activity); - } - - if method == Some("thread/tokenUsage/updated") - && message_turn_id == Some(turn_id) - && let Some(usage) = parse_usage(params.get("tokenUsage")) - { - token_usage = Some(usage); - } - - if method == Some("turn/completed") && message_turn_id == Some(turn_id) { - debug("codex turn completed"); - if let Some(error) = params - .get("turn") - .and_then(|turn| turn.get("error")) - .filter(|error| !error.is_null()) - { - return Err(anyhow!("codex turn failed: {error}")); - } - - if text.trim().is_empty() { - return Err(anyhow!("codex turn completed without final answer")); - } - - return Ok(TurnOutput { - text, - token_usage, - activities, - }); - } - } - } - - async fn next_message(&mut self) -> Result { - loop { - let result = { - let process = self - .process - .as_mut() - .ok_or_else(|| anyhow!("codex app-server process unavailable"))?; - process.stdout.next_line().await - }; - let line = match result { - Ok(Some(line)) => line, - Ok(None) => { - self.invalidate_process(); - return Err(anyhow!("codex app-server closed stdout")); - } - Err(error) => { - self.invalidate_process(); - return Err(error.into()); - } - }; - - if line.trim().is_empty() { - continue; - } - - return Ok(serde_json::from_str(&line)?); - } - } - - async fn send_line(&mut self, line: &str) -> Result<()> { - let result = async { - let process = self - .process - .as_mut() - .ok_or_else(|| anyhow!("codex app-server process unavailable"))?; - process.stdin.write_all(line.as_bytes()).await?; - process.stdin.write_all(b"\n").await?; - process.stdin.flush().await?; - - Ok(()) - } - .await; - - if result.is_err() { - self.invalidate_process(); - } - - result - } - - async fn handle_server_request(&mut self, message: &Value) -> Result { - let Some(id) = message.get("id").cloned() else { - return Ok(false); - }; - let Some(method) = message.get("method").and_then(Value::as_str) else { - return Ok(false); - }; - - let response = match method { - "item/commandExecution/requestApproval" | "execCommandApproval" => { - json!({"id": id, "result": {"decision": "decline"}}) - } - "item/fileChange/requestApproval" | "applyPatchApproval" => { - json!({"id": id, "result": {"decision": "decline"}}) - } - "item/permissions/requestApproval" => { - json!({"id": id, "result": {"permissions": {}, "scope": "turn", "strictAutoReview": true}}) - } - "item/tool/call" => { - json!({"id": id, "result": {"contentItems": [], "success": false}}) - } - "item/tool/requestUserInput" => json!({"id": id, "result": {"answers": {}}}), - "mcpServer/elicitation/request" => { - json!({"id": id, "result": {"action": "decline", "content": null, "_meta": null}}) - } - "account/chatgptAuthTokens/refresh" | "attestation/generate" => { - json!({"id": id, "error": {"code": -32603, "message": "Loopbiotic does not handle this app-server request"}}) - } - _ => return Ok(false), - }; - - debug(&format!("handled codex server request {method}")); - - let line = serde_json::to_string(&response)?; - self.send_line(&line).await?; - - Ok(true) - } -} - -#[async_trait] -impl BackendAdapter for CodexAppBackend { - async fn next_card(&self, req: BackendRequest) -> Result { - self.next_card_with_progress(req, None).await - } - - async fn next_card_with_progress( - &self, - req: BackendRequest, - progress: Option, - ) -> Result { - let output = self.ask(&req, progress.as_ref()).await?; - let card = parse_card(&output.text, &req.card_contract) - .unwrap_or_else(|error| Self::error_card(format!("{}\n\n{}", error, output.text))); - let card = enforce_card_contract(card, &req.card_contract, "Codex", &output.text); - - Ok(BackendResponse { - card, - raw_output: Some(output.text.clone()), - metadata: BackendMetadata { - backend: "codex_app".into(), - model: self.model.clone(), - token_usage: output.token_usage.or_else(|| { - Some(TokenUsage::estimated( - estimate_tokens(&prompt(&req, true)), - estimate_tokens(&output.text), - )) - }), - activities: output.activities, - attempts: vec![], - }, - }) - } - - async fn warmup(&self) -> Result<()> { - self.warm_up().await - } - - fn capabilities(&self) -> BackendInfo { - BackendInfo { - name: "codex_app".into(), - streaming: true, - patches: true, - reasoning: true, - can_read_project: true, - can_use_tools: true, - } - } -} - -fn prompt(req: &BackendRequest, include_context: bool) -> String { - let patch_turn = turn_phase(req) == Phase::Patch; - let goal_loop = req.card_contract.allow_goal_completion; - let goal_question = goal_loop - && req.card_contract.expected_kind == Some(loopbiotic_protocol::CardKind::Finding); - let turn_rules = if goal_question { - "- Explain why the currently pending patch is the right next step for the original goal.\n\ - - Address its behavior, tradeoffs, and relevant evidence from the code.\n\ - - Return one concise finding. Do not draft, replace, advance, or complete the goal.\n\ - - The exact pending patch remains awaiting user acceptance after this answer." - .into() - } else if goal_loop { - format!( - "- Continue executing the original session goal from the accepted progress; never restart or repeat a completed step.\n\ - - Inspect every required project file with targeted read-only tools and prepare the complete change in this turn.\n\ - - Tool reads are valid patch source. Loopbiotic verifies every returned hunk against the corresponding live editor buffer before review.\n\ - - Return one structured patch batch with up to {} files, up to {} hunks per file, and at most {} added/removed lines per hunk. Include every required edit; review granularity is handled locally.\n\ - - Create missing files directly in the same batch before patches that reference them.\n\ - - Use open_location only when a required source cannot be inspected with read-only project tools.\n\ - - Set goal_complete=true when accepting the complete returned patch finishes the original goal. Set it false only when another file or independently inspected stage remains.\n\ - - Return summary only when every requirement in the original goal is satisfied; cite the completed result.\n\ - - Return choice only when a genuine user decision blocks all safe progress.\n\ - - Do not return a finding, an assessment, a plan, or instructions for the user to request another draft.", - req.card_contract.max_patch_files, - req.card_contract.max_hunks_per_patch, - req.card_contract.max_changed_lines, - ) - } else if patch_turn { - format!( - "- Return exactly one file and exactly one hunk changing at most {} added/removed lines.\n\ - - Change one coherent local block in the supplied excerpt. Leave later blocks for later Loopbiotic cards.\n\ - - Explain why this draft is the useful next move, not merely what lines it changes.\n\ - - The step must be internally coherent: do not introduce undefined symbols or dangling references.\n\ - - The code must remain type-correct after this hunk. Never change a field type while deferring its producer/initializer to a later card.\n\ - - If a safe step needs unseen references or more changed lines, limit this hunk to self-contained preparation such as adding only the new struct definition.\n\ - - Context and remove lines must be exact, contiguous source lines from the supplied buffer; never omit source lines between two context lines.\n\ - - Use only the supplied buffer excerpt. Do not inspect the project or use tools.", - req.card_contract.max_changed_lines - ) - } else { - "- Find only one useful next move, not a plan for the whole solution.\n\ - - Inspect the supplied ranked project context first. Use targeted project search only when those fragments are insufficient.\n\ - - Do not stop just because the initial excerpt is indirect or missing.\n\ - - When the user names a destination or consumer such as a template, API, caller, or renderer, prefer that consumer block as the next location before changing its producer.\n\ - - Explain what you noticed, why it matters now, and how the code led you there. Do not dictate keystrokes or a line-by-line walkthrough.\n\ - - Return a concrete evidence/next/location pointing to that block so the editor can move there before Fix.\n\ - - Do not propose code changes yet; hand the keyboard back after identifying the next move." - .into() - }; - - let output_contract = if goal_question { - "- finding: concise explanation of the pending hunk" - } else if goal_loop { - "- patch: one complete structured patch for local hunk-by-hunk review; include goal_complete\n\ -- open_location: when the next hunk belongs in another buffer; put the target in location (not next) and the explanation in reason (not message)\n\ -- choice: only for a blocking user decision\n\ -- summary: only when the complete original goal is satisfied" - } else { - "- hypothesis: {\"op\":\"hypothesis\",\"title\":string,\"claim\":string,\"evidence\":object|null,\"next\":object|null}\n\ -- finding: {\"op\":\"finding\",\"title\":string,\"finding\":string,\"location\":object|null,\"annotation\":string|null}\n\ -- patch: use the exact structured patch schema supplied by the API. Each hunk has old_start, new_start, and lines with kind context/remove/add plus line text without a diff prefix.\n\ -- error: {\"op\":\"error\",\"title\":string,\"message\":string}" - }; - - let ranked_context = if (patch_turn && !goal_loop) || req.context.artifacts.is_empty() { - "none".into() - } else { - req.context - .artifacts - .iter() - .map(|artifact| { - format!( - "--- {}:{}-{} ({:?}; {}) ---\n{}", - artifact.file.display(), - artifact.start_line, - artifact.end_line, - artifact.kind, - artifact.reason, - artifact.text - ) - }) - .collect::>() - .join("\n") - }; - - let source_context = if include_context { - format!( - "File: {}\nCursor: {}:{}\nBuffer starts at file line: {}\nBuffer excerpt:\n```text\n{}\n```\nRanked project context (read before using tools):\n```text\n{}\n```", - req.context.file.display(), - req.context.cursor.line, - req.context.cursor.column, - req.context.buffer_start_line, - req.context.buffer_text, - ranked_context, - ) - } else { - "Source context is unchanged from the preceding turn in this Loopbiotic thread. Reuse that exact buffer and ranked project context.".into() - }; - - format!( - r#"Return exactly one JSON Loopbiotic op. No markdown. No prose. - -Allowed ops: -{output_contract} - -Rules: -- Required card kind: {expected_kind}. Return that exact kind. -- Patch file paths must be relative. -{turn_rules} - -Session prompt: {prompt} -Completed local steps: {completed_steps} -Known findings and signals (do not repeat): {known_observations} -Mode: {mode} -Action: {action} -Last card: {last} -{source_context}"#, - prompt = req.session.prompt, - completed_steps = - serde_json::to_string(&req.session.completed_steps).unwrap_or_else(|_| "[]".into()), - known_observations = - serde_json::to_string(&req.session.known_observations).unwrap_or_else(|_| "[]".into()), - mode = serde_json::to_string(&req.session.mode).unwrap_or_else(|_| "\"auto\"".into()), - action = action_value(&req.action), - expected_kind = req - .card_contract - .expected_kind - .map(|kind| format!("{kind:?}").to_lowercase()) - .unwrap_or_else(|| "any allowed kind".into()), - turn_rules = turn_rules, - output_contract = output_contract, - last = req.session.last_summary.as_deref().unwrap_or("none"), - source_context = source_context, - ) -} - -fn context_fingerprint(req: &BackendRequest) -> u64 { - let mut hasher = DefaultHasher::new(); - req.context.file.hash(&mut hasher); - req.context.cursor.line.hash(&mut hasher); - req.context.cursor.column.hash(&mut hasher); - req.context.buffer_start_line.hash(&mut hasher); - req.context.buffer_text.hash(&mut hasher); - for diagnostic in &req.context.diagnostics { - diagnostic.file.hash(&mut hasher); - diagnostic.line.hash(&mut hasher); - diagnostic.message.hash(&mut hasher); - } - for artifact in &req.context.artifacts { - artifact.file.hash(&mut hasher); - artifact.start_line.hash(&mut hasher); - artifact.text.hash(&mut hasher); - } - hasher.finish() -} - -fn action_value(action: &BackendAction) -> Value { - match action { - BackendAction::Start => json!({"kind": "start"}), - BackendAction::User(action) => { - json!({"kind": "user", "action": serde_json::to_value(action).unwrap_or_default()}) - } - BackendAction::Reply(text) => json!({"kind": "reply", "text": text}), - BackendAction::ContractRetry(reason) => { - json!({"kind": "contract_retry", "reason": reason}) - } - BackendAction::LocationGranted => json!({"kind": "location_granted"}), - } -} - -fn parse_card(output: &str, contract: &crate::CardContract) -> Result { - if contract.allow_goal_completion { - let value = serde_json::from_str::(output.trim())?; - if value.get("op").and_then(Value::as_str) == Some("patch") { - return parse_structured_patch(output); - } - } - if contract.expected_kind == Some(loopbiotic_protocol::CardKind::Patch) { - return parse_structured_patch(output); - } - - let op = serde_json::from_str::(output.trim())?; - - Ok(op.into_card("c_agent")) -} - -fn parse_structured_patch(output: &str) -> Result { - let op = serde_json::from_str::(output.trim())?; - if op.op != "patch" { - return Err(anyhow!("codex returned op {:?}, expected patch", op.op)); - } - - let patches = op - .patches - .into_iter() - .enumerate() - .map(|(index, patch)| { - Ok(loopbiotic_protocol::FilePatch { - id: patch.id.unwrap_or_else(|| format!("p_{}", index + 1)), - file: patch.file, - diff: render_structured_diff(&patch.hunks)?, - explanation: patch.explanation, - }) - }) - .collect::>>()?; - - Ok(Card::Patch(loopbiotic_protocol::PatchCard { - id: "c_agent".into(), - title: op.title, - explanation: op.explanation, - warnings: vec![], - goal_complete: op.goal_complete, - patches, - actions: vec![ - Action::Apply, - Action::Why, - Action::Retry, - Action::EditPrompt, - Action::Stop, - ], - })) -} - -fn render_structured_diff(hunks: &[StructuredHunk]) -> Result { - let mut diff = String::new(); - - for hunk in hunks { - let old_len = hunk - .lines - .iter() - .filter(|line| !matches!(line.kind, StructuredLineKind::Add)) - .count(); - let new_len = hunk - .lines - .iter() - .filter(|line| !matches!(line.kind, StructuredLineKind::Remove)) - .count(); - - if hunk.old_start == 0 || hunk.new_start == 0 { - return Err(anyhow!("structured patch line numbers must start at 1")); - } - if hunk.lines.is_empty() { - return Err(anyhow!("structured patch hunk has no lines")); - } - - diff.push_str(&format!( - "@@ -{},{} +{},{} @@\n", - hunk.old_start, old_len, hunk.new_start, new_len - )); - - for line in &hunk.lines { - if line.text.contains(['\n', '\r']) { - return Err(anyhow!("structured patch line contains a newline")); - } - - let prefix = match line.kind { - StructuredLineKind::Context => ' ', - StructuredLineKind::Remove => '-', - StructuredLineKind::Add => '+', - }; - diff.push(prefix); - diff.push_str(&line.text); - diff.push('\n'); - } - } - - Ok(diff) -} - -fn output_schema(req: &BackendRequest) -> Value { - if req.card_contract.allow_goal_completion - && req.card_contract.expected_kind == Some(loopbiotic_protocol::CardKind::Finding) - { - return finding_schema(); - } - if req.card_contract.allow_goal_completion { - return goal_loop_schema(&req.card_contract); - } - - match req.card_contract.expected_kind { - Some(loopbiotic_protocol::CardKind::Patch) => patch_schema(&req.card_contract), - Some(loopbiotic_protocol::CardKind::Hypothesis) => hypothesis_schema(), - Some(loopbiotic_protocol::CardKind::Finding) => finding_schema(), - Some(loopbiotic_protocol::CardKind::Choice) => choice_schema(), - Some(loopbiotic_protocol::CardKind::Deny) => deny_schema(), - Some(loopbiotic_protocol::CardKind::Summary) => summary_schema(), - Some(loopbiotic_protocol::CardKind::Error) => error_schema(), - Some(loopbiotic_protocol::CardKind::OpenLocation) | None => any_op_schema(), - } -} - -/// Schema for turns without a demanded kind: the agent picks whichever op -/// fits, including a clarifying choice or a deny. Mirrors -/// schemas/loopbiotic-agent-op.schema.json (every field present, unused ones null). -fn any_op_schema() -> Value { - object_schema( - &[ - "op", - "title", - "claim", - "evidence", - "next", - "finding", - "location", - "annotation", - "explanation", - "goal_complete", - "patches", - "question", - "options", - "reason", - "summary", - "changed_files", - "message", - ], - json!({ - "op": {"type": "string", "enum": ["hypothesis", "finding", "patch", "choice", "deny", "open_location", "summary", "error"]}, - "title": {"type": "string"}, - "claim": {"type": ["string", "null"]}, - "evidence": nullable_location_schema(), - "next": nullable_location_schema(), - "finding": {"type": ["string", "null"]}, - "location": nullable_location_schema(), - "annotation": {"type": ["string", "null"]}, - "explanation": {"type": ["string", "null"]}, - "goal_complete": {"type": ["boolean", "null"]}, - "patches": { - "type": ["array", "null"], - "items": object_schema( - &["id", "file", "diff", "explanation"], - json!({ - "id": {"type": ["string", "null"]}, - "file": {"type": "string"}, - "diff": {"type": "string"}, - "explanation": {"type": "string"} - }) - ) - }, - "question": {"type": ["string", "null"]}, - "options": { - "type": ["array", "null"], - "items": object_schema( - &["id", "label", "action"], - json!({ - "id": {"type": "string"}, - "label": {"type": "string"}, - "action": { - "type": "string", - "enum": ["follow", "why", "fix", "other_lead", "retry", "edit_prompt", "open", "run_check", "next", "stop"] - } - }) - ) - }, - "reason": {"type": ["string", "null"]}, - "summary": {"type": ["string", "null"]}, - "changed_files": {"type": ["array", "null"], "items": {"type": "string"}}, - "message": {"type": ["string", "null"]} - }), - ) -} - -fn goal_loop_schema(contract: &crate::CardContract) -> Value { - let mut schema = any_op_schema(); - schema["properties"]["op"]["enum"] = json!([ - "patch", - "choice", - "deny", - "open_location", - "summary", - "error" - ]); - let mut patches = patch_schema(contract)["properties"]["patches"].clone(); - patches["type"] = json!(["array", "null"]); - schema["properties"]["patches"] = patches; - schema -} - -fn object_schema(required: &[&str], properties: Value) -> Value { - json!({ - "type": "object", - "required": required, - "properties": properties, - "additionalProperties": false - }) -} - -fn nullable_location_schema() -> Value { - json!({ - "anyOf": [ - object_schema( - &["file", "line", "column", "annotation"], - json!({ - "file": {"type": "string"}, - "line": {"type": "integer"}, - "column": {"type": "integer"}, - "annotation": {"type": ["string", "null"]} - }) - ), - {"type": "null"} - ] - }) -} - -fn location_schema() -> Value { - object_schema( - &["file", "line", "column", "annotation"], - json!({ - "file": {"type": "string"}, - "line": {"type": "integer", "minimum": 1}, - "column": {"type": "integer", "minimum": 1}, - "annotation": {"type": ["string", "null"]} - }), - ) -} - -fn hypothesis_schema() -> Value { - object_schema( - &["op", "title", "claim", "evidence", "next"], - json!({ - "op": {"type": "string", "enum": ["hypothesis"]}, - "title": {"type": "string"}, - "claim": {"type": "string"}, - "evidence": nullable_location_schema(), - "next": location_schema() - }), - ) -} - -fn finding_schema() -> Value { - object_schema( - &["op", "title", "finding", "location", "annotation"], - json!({ - "op": {"type": "string", "enum": ["finding"]}, - "title": {"type": "string"}, - "finding": {"type": "string"}, - "location": location_schema(), - "annotation": {"type": ["string", "null"]} - }), - ) -} - -fn patch_schema(contract: &crate::CardContract) -> Value { - object_schema( - &["op", "title", "explanation", "goal_complete", "patches"], - json!({ - "op": {"type": "string", "enum": ["patch"]}, - "title": {"type": "string"}, - "explanation": {"type": "string"}, - "goal_complete": {"type": "boolean"}, - "patches": { - "type": "array", - "minItems": 1, - "maxItems": contract.max_patch_files, - "items": object_schema( - &["id", "file", "explanation", "hunks"], - json!({ - "id": {"type": ["string", "null"]}, - "file": {"type": "string"}, - "explanation": {"type": "string"}, - "hunks": { - "type": "array", - "minItems": 1, - "maxItems": contract.max_hunks_per_patch, - "items": object_schema( - &["old_start", "new_start", "lines"], - json!({ - "old_start": {"type": "integer", "minimum": 1}, - "new_start": {"type": "integer", "minimum": 1}, - "lines": { - "type": "array", - "minItems": 1, - "maxItems": contract.max_changed_lines + 8, - "items": object_schema( - &["kind", "text"], - json!({ - "kind": {"type": "string", "enum": ["context", "remove", "add"]}, - "text": {"type": "string"} - }) - ) - } - }) - ) - } - }) - ) - } - }), - ) -} - -fn choice_schema() -> Value { - object_schema( - &["op", "title", "question", "options"], - json!({ - "op": {"type": "string", "enum": ["choice"]}, - "title": {"type": "string"}, - "question": {"type": "string"}, - "options": { - "type": "array", - "items": object_schema( - &["id", "label", "action"], - json!({ - "id": {"type": "string"}, - "label": {"type": "string"}, - "action": { - "type": "string", - "enum": ["follow", "why", "fix", "other_lead", "retry", "edit_prompt", "open", "run_check", "next", "stop"] - } - }) - ) - } - }), - ) -} - -fn summary_schema() -> Value { - object_schema( - &["op", "title", "summary", "changed_files"], - json!({ - "op": {"type": "string", "enum": ["summary"]}, - "title": {"type": "string"}, - "summary": {"type": "string"}, - "changed_files": {"type": "array", "items": {"type": "string"}} - }), - ) -} - -fn deny_schema() -> Value { - object_schema( - &["op", "title", "reason", "location"], - json!({ - "op": {"type": "string", "enum": ["deny"]}, - "title": {"type": "string"}, - "reason": {"type": "string"}, - "location": nullable_location_schema() - }), - ) -} - -fn error_schema() -> Value { - object_schema( - &["op", "title", "message"], - json!({ - "op": {"type": "string", "enum": ["error"]}, - "title": {"type": "string"}, - "message": {"type": "string"} - }), - ) -} - -fn parse_usage(value: Option<&Value>) -> Option { - let last = value?.get("last")?; - let input = last.get("inputTokens")?.as_u64()? as usize; - let cached_input = last - .get("cachedInputTokens") - .and_then(Value::as_u64) - .unwrap_or_default() as usize; - let output = last.get("outputTokens")?.as_u64()? as usize; - let total = last.get("totalTokens")?.as_u64()? as usize; - - Some(TokenUsage { - input_tokens: input, - cached_input_tokens: cached_input, - output_tokens: output, - total_tokens: total, - estimated: false, - }) -} - -fn args_from_env(json_name: &str, plain_name: &str) -> Result> { - if let Ok(value) = std::env::var(json_name) - && !value.trim().is_empty() - { - return Ok(serde_json::from_str(&value)?); - } - - Ok(std::env::var(plain_name) - .unwrap_or_else(|_| "app-server --stdio".into()) - .split_whitespace() - .map(str::to_string) - .collect()) -} - -fn debug(message: &str) { - if std::env::var("LOOPBIOTIC_DEBUG").is_ok() { - eprintln!("loopbiotic codex_app: {message}"); - } -} - -fn message_turn_id(params: &Value) -> Option<&str> { - params.get("turnId").and_then(Value::as_str).or_else(|| { - params - .get("turn") - .and_then(|turn| turn.get("id")) - .and_then(Value::as_str) - }) -} - -fn activity_summary(item: &Value) -> Option { - let kind = item.get("type").and_then(Value::as_str)?; - if matches!(kind, "reasoning" | "agentMessage" | "plan") { - return None; - } - - let detail = match kind { - "commandExecution" => item.get("command").map(compact_value), - "fileChange" => item - .get("path") - .or_else(|| item.get("changes")) - .map(compact_value), - "mcpToolCall" => { - let server = item.get("server").and_then(Value::as_str).unwrap_or("mcp"); - let tool = item - .get("tool") - .or_else(|| item.get("name")) - .and_then(Value::as_str) - .unwrap_or("tool"); - Some(format!("{server}/{tool}")) - } - "webSearch" => item.get("query").map(compact_value), - "dynamicToolCall" | "toolCall" => item - .get("tool") - .or_else(|| item.get("name")) - .map(compact_value), - _ if kind.to_lowercase().contains("tool") || kind.to_lowercase().contains("command") => { - item.get("name").map(compact_value) - } - _ => return None, - }; - let detail = detail.filter(|value| !value.is_empty()); - Some(match detail { - Some(detail) => format!("{kind}: {detail}"), - None => kind.to_string(), - }) -} - -fn compact_value(value: &Value) -> String { - let value = value - .as_str() - .map(str::to_owned) - .unwrap_or_else(|| value.to_string()); - let mut compact = value.split_whitespace().collect::>().join(" "); - if compact.chars().count() > 240 { - compact = compact.chars().take(240).collect::(); - compact.push_str("..."); - } - compact -} - -fn report_progress( - progress: Option<&ProgressReporter>, - session_id: &str, - phase: &str, - message: &str, -) { - if let Some(progress) = progress { - progress(BackendProgress { - session_id: session_id.into(), - phase: phase.into(), - message: message.into(), - }); - } -} - -fn progress_for_message(message: &Value, turn_id: &str) -> Option<(&'static str, &'static str)> { - let params = message.get("params")?; - - if message_turn_id(params) != Some(turn_id) { - return None; - } - - match message.get("method").and_then(Value::as_str) { - Some("turn/started") => Some(("working", "Codex is processing the request")), - Some("item/started") => match params - .get("item") - .and_then(|item| item.get("type")) - .and_then(Value::as_str) - { - Some("reasoning") => Some(("reviewing", "Codex is reviewing the provided context")), - Some("agentMessage") => Some(("responding", "Codex is preparing a response")), - _ => Some(("working", "Codex is processing the request")), - }, - Some("item/completed") - if params - .get("item") - .and_then(|item| item.get("type")) - .and_then(Value::as_str) - == Some("agentMessage") => - { - Some(("validating", "Codex is validating the response")) - } - Some("turn/completed") => Some(("finishing", "Codex completed the response")), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn request() -> BackendRequest { - BackendRequest { - session: crate::SessionSnapshot { - id: "s_1".into(), - prompt: "inspect target".into(), - completed_steps: vec![], - known_observations: vec![], - mode: loopbiotic_protocol::Mode::Auto, - card_count: 0, - last_card: None, - last_summary: None, - }, - action: BackendAction::Start, - context: loopbiotic_protocol::ContextBundle { - cwd: "/tmp/project".into(), - file: "src/main.rs".into(), - cursor: loopbiotic_protocol::Cursor { line: 1, column: 1 }, - selection: None, - buffer_text: "unique source payload".into(), - buffer_start_line: 1, - diagnostics: vec![], - hints: vec![], - artifacts: vec![], - report: None, - }, - card_contract: crate::CardContract { - expected_kind: Some(loopbiotic_protocol::CardKind::Hypothesis), - ..Default::default() - }, - } - } - - #[test] - fn routes_discovery_and_patch_turns_to_separate_phases() { - let mut request = request(); - assert_eq!(turn_phase(&request), Phase::Discovery); - - request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Patch); - assert_eq!(turn_phase(&request), Phase::Patch); - - request.card_contract.expected_kind = None; - request.card_contract.allow_goal_completion = true; - assert_eq!(turn_phase(&request), Phase::Patch); - assert_eq!(thread_key(&request), "s_1:goal"); - } - - #[tokio::test] - async fn discovery_and_patch_lanes_do_not_share_a_turn_lock() { - let backend = CodexAppBackend::new("unused", vec![], None, None); - let discovery = backend.lane(Phase::Discovery); - let patch = backend.lane(Phase::Patch); - - assert!(!Arc::ptr_eq(&discovery, &patch)); - let _discovery_guard = discovery.lock().await; - let patch_guard = - tokio::time::timeout(std::time::Duration::from_millis(50), patch.lock()).await; - - assert!(patch_guard.is_ok(), "patch lane waited on discovery lane"); - } - - #[test] - fn invalidating_a_process_discards_ephemeral_thread_state() { - let mut state = CodexAppState::default(); - state.threads.insert("key".into(), "thread".into()); - state.context_fingerprints.insert("thread".into(), 42); - - state.invalidate_process(); - - assert!(state.process.is_none()); - assert!(state.threads.is_empty()); - assert!(state.context_fingerprints.is_empty()); - } - - #[test] - fn serializes_user_action_as_protocol_value() { - let value = action_value(&BackendAction::User(Action::Fix)); - - assert_eq!(value["action"], "fix"); - } - - #[test] - fn unchanged_context_is_not_repeated_in_thread_prompt() { - let request = request(); - - assert!(prompt(&request, true).contains("unique source payload")); - let repeated = prompt(&request, false); - assert!(!repeated.contains("unique source payload")); - assert!(repeated.contains("Source context is unchanged")); - } - - #[test] - fn accepted_patch_step_rotates_patch_thread() { - let mut request = request(); - request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Patch); - let first = thread_key(&request); - request.session.completed_steps.push("first patch".into()); - - assert_ne!(first, thread_key(&request)); - assert_eq!(thread_key(&request), "s_1:patch:1"); - } - - #[test] - fn retry_within_the_same_step_reuses_patch_thread() { - let mut request = request(); - request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Patch); - let first = thread_key(&request); - request.action = BackendAction::ContractRetry("repair it".into()); - - assert_eq!(first, thread_key(&request)); - } - - #[test] - fn summarizes_tool_activity_without_reasoning_text() { - let command = json!({ - "type": "commandExecution", - "command": "rg layout_editor.html templates" - }); - let reasoning = json!({"type": "reasoning", "text": "private"}); - - assert!( - activity_summary(&command) - .unwrap() - .contains("layout_editor.html") - ); - assert_eq!(activity_summary(&reasoning), None); - } - - #[test] - fn renders_typed_patch_hunks_as_unified_diff() { - let output = json!({ - "op": "patch", - "title": "Rename value", - "explanation": "Use the new name.", - "patches": [{ - "id": null, - "file": "src/main.rs", - "explanation": "Rename one line.", - "hunks": [{ - "old_start": 4, - "new_start": 4, - "lines": [ - {"kind": "context", "text": "fn main() {"}, - {"kind": "remove", "text": " let old = 1;"}, - {"kind": "add", "text": " let new = 1;"}, - {"kind": "context", "text": "}"} - ] - }] - }] - }); - - let Card::Patch(card) = parse_structured_patch(&output.to_string()).unwrap() else { - panic!("expected patch card"); - }; - - assert_eq!( - card.patches[0].diff, - "@@ -4,3 +4,3 @@\n fn main() {\n- let old = 1;\n+ let new = 1;\n }\n" - ); - } - - #[test] - fn goal_loop_dispatches_structured_patch_output() { - let output = json!({ - "op": "patch", - "title": "Continue the goal", - "explanation": "Apply the next accepted requirement.", - "goal_complete": true, - "patches": [{ - "id": null, - "file": "src/main.rs", - "explanation": "Update the next local block.", - "hunks": [{ - "old_start": 1, - "new_start": 1, - "lines": [ - {"kind": "remove", "text": "old"}, - {"kind": "add", "text": "new"} - ] - }] - }], - "claim": null, - "evidence": null, - "next": null, - "finding": null, - "location": null, - "annotation": null, - "question": null, - "options": null, - "reason": null, - "summary": null, - "changed_files": null, - "message": null - }); - let contract = crate::CardContract { - allow_goal_completion: true, - ..Default::default() - }; - - let Card::Patch(card) = parse_card(&output.to_string(), &contract).unwrap() else { - panic!("expected patch card"); - }; - assert!(card.goal_complete); - } - - #[test] - fn goal_loop_accepts_open_location_target_in_next() { - let output = json!({ - "op": "open_location", - "title": "Open inactive-account exception", - "reason": "Create the exception before referencing it.", - "location": null, - "next": { - "file": "src/Exception/OAuth/OAuthAccountNotActiveException.php", - "line": 1, - "column": 1, - "annotation": "New exception type." - } - }); - let contract = crate::CardContract { - allow_goal_completion: true, - ..Default::default() - }; - - let Card::OpenLocation(card) = parse_card(&output.to_string(), &contract).unwrap() else { - panic!("expected open_location card"); - }; - assert!( - card.location - .file - .ends_with("OAuthAccountNotActiveException.php") - ); - } - - #[test] - fn strict_parser_rejects_prose_around_json() { - let output = r#"Here is the result: {"op":"finding","title":"T","finding":"F","location":null,"annotation":null}"#; - let contract = crate::CardContract { - expected_kind: Some(loopbiotic_protocol::CardKind::Finding), - ..crate::CardContract::default() - }; - - assert!(parse_card(output, &contract).is_err()); - } - - #[test] - fn patch_schema_exposes_hunks_instead_of_raw_diff() { - let schema = patch_schema(&crate::CardContract::default()); - let patch = &schema["properties"]["patches"]["items"]; - - assert!(patch["properties"].get("diff").is_none()); - assert_eq!(patch["properties"]["hunks"]["type"], "array"); - assert_eq!(schema["properties"]["patches"]["maxItems"], 1); - assert_eq!(patch["properties"]["hunks"]["maxItems"], 1); - } - - #[test] - fn goal_loop_schema_allows_structured_patch_or_summary() { - let contract = crate::CardContract { - max_patch_files: loopbiotic_protocol::MAX_GOAL_PATCH_FILES, - max_hunks_per_patch: loopbiotic_protocol::MAX_GOAL_HUNKS_PER_PATCH, - max_changed_lines: loopbiotic_protocol::MAX_GOAL_CHANGED_LINES, - ..Default::default() - }; - let schema = goal_loop_schema(&contract); - let ops = schema["properties"]["op"]["enum"].as_array().unwrap(); - - assert!(ops.contains(&json!("patch"))); - assert!(ops.contains(&json!("summary"))); - assert!(!ops.contains(&json!("finding"))); - assert_eq!( - schema["properties"]["patches"]["maxItems"], - loopbiotic_protocol::MAX_GOAL_PATCH_FILES - ); - assert!(schema["properties"]["patches"]["items"]["properties"]["hunks"].is_object()); - assert_eq!( - schema["properties"]["patches"]["items"]["properties"]["hunks"]["maxItems"], - loopbiotic_protocol::MAX_GOAL_HUNKS_PER_PATCH - ); - assert!(schema["properties"]["goal_complete"].is_object()); - } - - #[test] - fn why_uses_finding_schema_inside_the_goal_thread() { - let mut request = request(); - request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Finding); - request.card_contract.allow_goal_completion = true; - request.action = BackendAction::User(Action::Why); - let schema = output_schema(&request); - - assert_eq!(thread_key(&request), "s_1:goal"); - assert_eq!(schema["properties"]["op"]["enum"][0], "finding"); - assert!(prompt(&request, true).contains("pending patch remains")); - } - - #[test] - fn goal_prompt_requests_one_verified_multi_file_batch() { - let mut request = request(); - request.card_contract.allow_goal_completion = true; - request.card_contract.expected_kind = None; - let prompt = prompt(&request, true); - - assert!(prompt.contains("Tool reads are valid patch source")); - assert!(prompt.contains("complete change in this turn")); - assert!(prompt.contains("Create missing files directly in the same batch")); - } - - #[test] - fn discovery_schema_requires_a_concrete_next_location() { - let schema = hypothesis_schema(); - - assert_eq!(schema["properties"]["next"]["type"], "object"); - assert_eq!( - schema["properties"]["next"]["properties"]["line"]["minimum"], - 1 - ); - } - - #[test] - fn parses_usage_from_app_server_notification() { - let value = json!({ - "last": { - "inputTokens": 10, - "cachedInputTokens": 8, - "outputTokens": 5, - "totalTokens": 15 - } - }); - let usage = parse_usage(Some(&value)).unwrap(); - - assert_eq!(usage.input_tokens, 10); - assert_eq!(usage.cached_input_tokens, 8); - assert_eq!(usage.output_tokens, 5); - assert!(!usage.estimated); - } - - #[test] - fn normalizes_progress_without_exposing_agent_text() { - let event = json!({ - "method": "item/started", - "params": { - "turnId": "turn_1", - "item": { - "type": "reasoning", - "text": "private model reasoning" - } - } - }); - - assert_eq!( - progress_for_message(&event, "turn_1"), - Some(("reviewing", "Codex is reviewing the provided context")) - ); - } - - #[test] - fn ignores_progress_for_another_turn() { - let event = json!({ - "method": "turn/completed", - "params": {"turnId": "turn_1"} - }); - - assert_eq!(progress_for_message(&event, "turn_2"), None); - } -} diff --git a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs new file mode 100644 index 0000000..444068f --- /dev/null +++ b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs @@ -0,0 +1,683 @@ +mod parse; +mod schema; +mod transport; + +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use loopbiotic_protocol::{BackendInfo, Card, TokenUsage}; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::sync::Mutex; + +use crate::support::{ + Phase, TurnTimedOut, action_value, args_from_env, await_turn, context_fingerprint, error_card, + optional_env, report_progress, turn_phase, turn_timeout_from_env, +}; +use crate::{ + BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, ProgressReporter, + enforce_card_contract, estimate_tokens, +}; + +use transport::{CodexAppProcess, CodexAppState, TurnOutput}; + +/// Keeps discovery and patch work on independent app-server processes. The +/// split lets a speculative patch run while the user continues discovery, +/// matching the phase-isolated process model used by the Claude adapter. +pub struct CodexAppBackend { + command: String, + args: Vec, + model: Option, + effort: Option, + turn_timeout: Option, + discovery: Arc>, + patch: Arc>, +} + +impl CodexAppBackend { + pub fn from_env() -> Result { + let command = std::env::var("LOOPBIOTIC_CODEX_COMMAND").unwrap_or_else(|_| "codex".into()); + let args = args_from_env( + "LOOPBIOTIC_CODEX_ARGS_JSON", + "LOOPBIOTIC_CODEX_ARGS", + "app-server --stdio", + )?; + let model = optional_env("LOOPBIOTIC_CODEX_MODEL"); + let effort = optional_env("LOOPBIOTIC_CODEX_EFFORT").or_else(|| Some("low".into())); + + Ok(Self::new(command, args, model, effort)) + } + + pub fn new( + command: impl Into, + args: Vec, + model: Option, + effort: Option, + ) -> Self { + Self::with_turn_timeout(command, args, model, effort, turn_timeout_from_env()) + } + + /// Internal constructor that fixes the per-turn deadline instead of + /// reading it from the environment; tests use it to avoid env races. + pub(crate) fn with_turn_timeout( + command: impl Into, + args: Vec, + model: Option, + effort: Option, + turn_timeout: Option, + ) -> Self { + Self { + command: command.into(), + args, + model, + effort, + turn_timeout, + discovery: Arc::new(Mutex::new(CodexAppState::default())), + patch: Arc::new(Mutex::new(CodexAppState::default())), + } + } + + fn lane(&self, phase: Phase) -> Arc> { + match phase { + Phase::Discovery => self.discovery.clone(), + Phase::Patch => self.patch.clone(), + } + } + + async fn ensure(state: &mut CodexAppState, command: &str, args: &[String]) -> Result<()> { + if state.process.is_some() { + return Ok(()); + } + + // Threads are ephemeral to one app-server process. Never carry their + // IDs or context cache into a replacement process after a crash. + state.clear_conversation(); + + debug("starting codex app-server"); + let mut child = Command::new(command) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn()?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("codex app-server stdin unavailable"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("codex app-server stdout unavailable"))?; + + state.process = Some(CodexAppProcess { + child, + stdin, + stdout: BufReader::new(stdout).lines(), + }); + + if let Err(error) = state + .request(json!({ + "method": "initialize", + "params": { + "clientInfo": { + "name": "loopbiotic", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { + "experimentalApi": true + } + } + })) + .await + { + state.invalidate_process(); + return Err(error); + } + debug("codex app-server initialized"); + + Ok(()) + } + + async fn thread_id( + state: &mut CodexAppState, + req: &BackendRequest, + model: &Option, + ) -> Result { + let patch_turn = turn_phase(req) == Phase::Patch; + let goal_loop = req.card_contract.allow_goal_completion; + let thread_key = thread_key(req); + + if let Some(thread_id) = state.threads.get(&thread_key) { + return Ok(thread_id.clone()); + } + + let base_instructions = if goal_loop { + "You are a local Loopbiotic coding agent executing one persistent goal. You may use targeted read-only project tools to inspect the repository and choose the next edit. Never edit files yourself. Return exactly one final JSON object matching the supplied output schema and no prose." + } else if patch_turn { + "You are a local Loopbiotic pair-programming partner. Do not use tools, commands, file reads, or repo inspection. Never edit files. Return exactly one final JSON object matching the supplied output schema and no prose." + } else { + "You are a local Loopbiotic pair-programming partner. You may use at most two targeted read-only project tool calls to find the next relevant code block. Stop searching once the supplied context supports an exact location. Never edit files. Return exactly one final JSON object matching the supplied output schema and no prose." + }; + let developer_instructions = if goal_loop { + "Drive the original goal from start to finish. In one work turn inspect every required file and return the complete multi-file patch batch; Loopbiotic reviews its hunks locally. When the user asks why, explain the pending hunk without advancing or replacing it. Preserve progress across turns and do not repeat accepted work." + } else if patch_turn { + "Work as an equal pair-programming partner. Propose one coherent local block at the supplied location and explain why this is the useful next move. Do not take over the whole task. Return one structured patch hunk as an editable draft, not a finished agenda." + } else { + "Work as an equal pair-programming partner. Inspect only enough code to identify one coherent next move. Explain what you noticed, why it matters, and how the code reveals it. Do not dictate line-by-line work or plan the whole task. Return one exact location so the keyboard can pass back to the user." + }; + + debug("starting codex thread"); + let response = state + .request(json!({ + "method": "thread/start", + "params": { + "cwd": req.context.cwd, + "sandbox": "read-only", + "approvalPolicy": "never", + "ephemeral": true, + "model": model, + "baseInstructions": base_instructions, + "developerInstructions": developer_instructions + } + })) + .await?; + + let thread_id = response + .get("thread") + .and_then(|thread| thread.get("id")) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("codex app-server thread/start returned no thread id"))? + .to_string(); + + state.threads.insert(thread_key, thread_id.clone()); + debug("codex thread started"); + + Ok(thread_id) + } + + async fn ask( + &self, + req: &BackendRequest, + progress: Option<&ProgressReporter>, + ) -> Result { + report_progress(progress, &req.session.id, "starting", "Starting Codex"); + let lane = self.lane(turn_phase(req)); + let mut state = lane.lock().await; + + let first = await_turn( + "Codex", + self.turn_timeout, + self.ask_once(&mut state, req, progress), + ) + .await; + let Err(first_error) = first else { + return first; + }; + if first_error.is::() { + // A wedged app-server would only wedge again; kill it, drop the + // lane's threads, and let the next turn spawn fresh. + state.kill_process(); + return Err(first_error); + } + if state.process.is_some() { + return Err(first_error); + } + + // Transport failure invalidates the whole lane. Retry once on a fresh + // app-server; invalidation cleared the old thread IDs and fingerprints, + // so this attempt necessarily sends the complete source context. + report_progress( + progress, + &req.session.id, + "restarting", + "Restarting the Codex session", + ); + let retry = await_turn( + "Codex", + self.turn_timeout, + self.ask_once(&mut state, req, progress), + ) + .await; + retry.map_err(|retry_error| { + if retry_error.is::() { + state.kill_process(); + } + anyhow!("codex connection failed: {first_error}; retry: {retry_error}") + }) + } + + async fn ask_once( + &self, + state: &mut CodexAppState, + req: &BackendRequest, + progress: Option<&ProgressReporter>, + ) -> Result { + Self::ensure(state, &self.command, &self.args).await?; + + let thread_id = Self::thread_id(state, req, &self.model).await?; + let fingerprint = context_fingerprint(req); + let include_context = state.context_fingerprints.get(&thread_id) != Some(&fingerprint); + state + .context_fingerprints + .insert(thread_id.clone(), fingerprint); + let input = prompt(req, include_context); + report_progress( + progress, + &req.session.id, + "requesting", + "Sending the request to Codex", + ); + debug("starting codex turn"); + let response = state + .request(json!({ + "method": "turn/start", + "params": { + "threadId": thread_id, + "cwd": req.context.cwd, + "approvalPolicy": "never", + "sandboxPolicy": { + "type": "readOnly", + "networkAccess": false + }, + "input": [{ + "type": "text", + "text": input, + "text_elements": [] + }], + "model": self.model, + "effort": self.effort, + "outputSchema": schema::output_schema(req) + } + })) + .await?; + + let turn_id = response + .get("turn") + .and_then(|turn| turn.get("id")) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("codex app-server turn/start returned no turn id"))?; + let turn_id = turn_id.to_string(); + debug("codex turn started"); + + report_progress( + progress, + &req.session.id, + "working", + "Codex is processing the request", + ); + state.read_turn(&turn_id, &req.session.id, progress).await + } + + async fn warm_up(&self) -> Result<()> { + let lane = self.lane(Phase::Discovery); + let mut state = lane.lock().await; + + Self::ensure(&mut state, &self.command, &self.args).await + } + + fn error_card(message: impl Into) -> Card { + error_card("c_codex_app_error", "Codex app-server error", message) + } +} + +fn thread_key(req: &BackendRequest) -> String { + if req.card_contract.allow_goal_completion { + return format!("{}:goal", req.session.id); + } + let patch_turn = turn_phase(req) == Phase::Patch; + format!( + "{}:{}:{}", + req.session.id, + if patch_turn { "patch" } else { "discover" }, + if patch_turn { + req.session.completed_steps.len() + } else { + 0 + } + ) +} + +#[async_trait] +impl BackendAdapter for CodexAppBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + self.next_card_with_progress(req, None).await + } + + async fn next_card_with_progress( + &self, + req: BackendRequest, + progress: Option, + ) -> Result { + let output = self.ask(&req, progress.as_ref()).await?; + let card = parse::parse_card(&output.text, &req.card_contract) + .unwrap_or_else(|error| Self::error_card(format!("{}\n\n{}", error, output.text))); + let card = enforce_card_contract(card, &req.card_contract, "Codex", &output.text); + + Ok(BackendResponse { + card, + raw_output: Some(output.text.clone()), + metadata: BackendMetadata { + backend: "codex_app".into(), + model: self.model.clone(), + token_usage: output.token_usage.or_else(|| { + Some(TokenUsage::estimated( + estimate_tokens(&prompt(&req, true)), + estimate_tokens(&output.text), + )) + }), + activities: output.activities, + attempts: vec![], + }, + }) + } + + async fn warmup(&self) -> Result<()> { + self.warm_up().await + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "codex_app".into(), + streaming: true, + patches: true, + reasoning: true, + can_read_project: true, + can_use_tools: true, + } + } +} + +fn prompt(req: &BackendRequest, include_context: bool) -> String { + let patch_turn = turn_phase(req) == Phase::Patch; + let goal_loop = req.card_contract.allow_goal_completion; + let goal_question = goal_loop + && req.card_contract.expected_kind == Some(loopbiotic_protocol::CardKind::Finding); + let turn_rules = if goal_question { + "- Explain why the currently pending patch is the right next step for the original goal.\n\ + - Address its behavior, tradeoffs, and relevant evidence from the code.\n\ + - Return one concise finding. Do not draft, replace, advance, or complete the goal.\n\ + - The exact pending patch remains awaiting user acceptance after this answer." + .into() + } else if goal_loop { + format!( + "- Continue executing the original session goal from the accepted progress; never restart or repeat a completed step.\n\ + - Inspect every required project file with targeted read-only tools and prepare the complete change in this turn.\n\ + - Tool reads are valid patch source. Loopbiotic verifies every returned hunk against the corresponding live editor buffer before review.\n\ + - Return one structured patch batch with up to {} files, up to {} hunks per file, and at most {} added/removed lines per hunk. Include every required edit; review granularity is handled locally.\n\ + - Create missing files directly in the same batch before patches that reference them.\n\ + - Use open_location only when a required source cannot be inspected with read-only project tools.\n\ + - Set goal_complete=true when accepting the complete returned patch finishes the original goal. Set it false only when another file or independently inspected stage remains.\n\ + - Return summary only when every requirement in the original goal is satisfied; cite the completed result.\n\ + - Return choice only when a genuine user decision blocks all safe progress.\n\ + - Do not return a finding, an assessment, a plan, or instructions for the user to request another draft.", + req.card_contract.max_patch_files, + req.card_contract.max_hunks_per_patch, + req.card_contract.max_changed_lines, + ) + } else if patch_turn { + format!( + "- Return exactly one file and exactly one hunk changing at most {} added/removed lines.\n\ + - Change one coherent local block in the supplied excerpt. Leave later blocks for later Loopbiotic cards.\n\ + - Explain why this draft is the useful next move, not merely what lines it changes.\n\ + - The step must be internally coherent: do not introduce undefined symbols or dangling references.\n\ + - The code must remain type-correct after this hunk. Never change a field type while deferring its producer/initializer to a later card.\n\ + - If a safe step needs unseen references or more changed lines, limit this hunk to self-contained preparation such as adding only the new struct definition.\n\ + - Context and remove lines must be exact, contiguous source lines from the supplied buffer; never omit source lines between two context lines.\n\ + - Use only the supplied buffer excerpt. Do not inspect the project or use tools.", + req.card_contract.max_changed_lines + ) + } else { + "- Find only one useful next move, not a plan for the whole solution.\n\ + - Inspect the supplied ranked project context first. Use targeted project search only when those fragments are insufficient.\n\ + - Do not stop just because the initial excerpt is indirect or missing.\n\ + - When the user names a destination or consumer such as a template, API, caller, or renderer, prefer that consumer block as the next location before changing its producer.\n\ + - Explain what you noticed, why it matters now, and how the code led you there. Do not dictate keystrokes or a line-by-line walkthrough.\n\ + - Return a concrete evidence/next/location pointing to that block so the editor can move there before Fix.\n\ + - Do not propose code changes yet; hand the keyboard back after identifying the next move." + .into() + }; + + let output_contract = if goal_question { + "- finding: concise explanation of the pending hunk" + } else if goal_loop { + "- patch: one complete structured patch for local hunk-by-hunk review; include goal_complete\n\ +- open_location: when the next hunk belongs in another buffer; put the target in location (not next) and the explanation in reason (not message)\n\ +- choice: only for a blocking user decision\n\ +- summary: only when the complete original goal is satisfied" + } else { + "- hypothesis: {\"op\":\"hypothesis\",\"title\":string,\"claim\":string,\"evidence\":object|null,\"next\":object|null}\n\ +- finding: {\"op\":\"finding\",\"title\":string,\"finding\":string,\"location\":object|null,\"annotation\":string|null}\n\ +- patch: use the exact structured patch schema supplied by the API. Each hunk has old_start, new_start, and lines with kind context/remove/add plus line text without a diff prefix.\n\ +- error: {\"op\":\"error\",\"title\":string,\"message\":string}" + }; + + let ranked_context = if (patch_turn && !goal_loop) || req.context.artifacts.is_empty() { + "none".into() + } else { + req.context + .artifacts + .iter() + .map(|artifact| { + format!( + "--- {}:{}-{} ({:?}; {}) ---\n{}", + artifact.file.display(), + artifact.start_line, + artifact.end_line, + artifact.kind, + artifact.reason, + artifact.text + ) + }) + .collect::>() + .join("\n") + }; + + let source_context = if include_context { + format!( + "File: {}\nCursor: {}:{}\nBuffer starts at file line: {}\nBuffer excerpt:\n```text\n{}\n```\nRanked project context (read before using tools):\n```text\n{}\n```", + req.context.file.display(), + req.context.cursor.line, + req.context.cursor.column, + req.context.buffer_start_line, + req.context.buffer_text, + ranked_context, + ) + } else { + "Source context is unchanged from the preceding turn in this Loopbiotic thread. Reuse that exact buffer and ranked project context.".into() + }; + + format!( + r#"Return exactly one JSON Loopbiotic op. No markdown. No prose. + +Allowed ops: +{output_contract} + +Rules: +- Required card kind: {expected_kind}. Return that exact kind. +- Patch file paths must be relative. +{turn_rules} + +Session prompt: {prompt} +Completed local steps: {completed_steps} +Known findings and signals (do not repeat): {known_observations} +Mode: {mode} +Action: {action} +Last card: {last} +{source_context}"#, + prompt = req.session.prompt, + completed_steps = + serde_json::to_string(&req.session.completed_steps).unwrap_or_else(|_| "[]".into()), + known_observations = + serde_json::to_string(&req.session.known_observations).unwrap_or_else(|_| "[]".into()), + mode = serde_json::to_string(&req.session.mode).unwrap_or_else(|_| "\"auto\"".into()), + action = action_value(&req.action), + expected_kind = req + .card_contract + .expected_kind + .map(|kind| format!("{kind:?}").to_lowercase()) + .unwrap_or_else(|| "any allowed kind".into()), + turn_rules = turn_rules, + output_contract = output_contract, + last = req.session.last_summary.as_deref().unwrap_or("none"), + source_context = source_context, + ) +} + +fn debug(message: &str) { + if std::env::var("LOOPBIOTIC_DEBUG").is_ok() { + eprintln!("loopbiotic codex_app: {message}"); + } +} + +#[cfg(test)] +mod tests { + use loopbiotic_protocol::Action; + + use crate::BackendAction; + + use super::*; + + fn request() -> BackendRequest { + BackendRequest { + session: crate::SessionSnapshot { + id: "s_1".into(), + prompt: "inspect target".into(), + completed_steps: vec![], + known_observations: vec![], + mode: loopbiotic_protocol::Mode::Auto, + card_count: 0, + last_card: None, + last_summary: None, + }, + action: BackendAction::Start, + context: loopbiotic_protocol::ContextBundle { + cwd: "/tmp/project".into(), + file: "src/main.rs".into(), + cursor: loopbiotic_protocol::Cursor { line: 1, column: 1 }, + selection: None, + buffer_text: "unique source payload".into(), + buffer_start_line: 1, + diagnostics: vec![], + hints: vec![], + artifacts: vec![], + report: None, + }, + card_contract: crate::CardContract { + expected_kind: Some(loopbiotic_protocol::CardKind::Hypothesis), + ..Default::default() + }, + } + } + + #[test] + fn routes_discovery_and_patch_turns_to_separate_phases() { + let mut request = request(); + assert_eq!(turn_phase(&request), Phase::Discovery); + + request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Patch); + assert_eq!(turn_phase(&request), Phase::Patch); + + request.card_contract.expected_kind = None; + request.card_contract.allow_goal_completion = true; + assert_eq!(turn_phase(&request), Phase::Patch); + assert_eq!(thread_key(&request), "s_1:goal"); + } + + #[tokio::test] + async fn discovery_and_patch_lanes_do_not_share_a_turn_lock() { + let backend = CodexAppBackend::new("unused", vec![], None, None); + let discovery = backend.lane(Phase::Discovery); + let patch = backend.lane(Phase::Patch); + + assert!(!Arc::ptr_eq(&discovery, &patch)); + let _discovery_guard = discovery.lock().await; + let patch_guard = + tokio::time::timeout(std::time::Duration::from_millis(50), patch.lock()).await; + + assert!(patch_guard.is_ok(), "patch lane waited on discovery lane"); + } + + #[tokio::test] + async fn wedged_app_server_times_out_and_invalidates_the_lane() { + // `sleep` accepts a spawn, swallows the initialize request, and never + // writes to stdout: exactly a wedged CLI (auth prompt, deadlock). + let backend = CodexAppBackend::with_turn_timeout( + "sleep", + vec!["30".into()], + None, + None, + Some(Duration::from_millis(100)), + ); + + let error = backend.ask(&request(), None).await.unwrap_err(); + + assert!(error.is::(), "unexpected error: {error}"); + let lane = backend.lane(Phase::Discovery); + assert!( + lane.lock().await.process.is_none(), + "timed-out process must be invalidated so the next turn spawns fresh" + ); + } + + #[test] + fn unchanged_context_is_not_repeated_in_thread_prompt() { + let request = request(); + + assert!(prompt(&request, true).contains("unique source payload")); + let repeated = prompt(&request, false); + assert!(!repeated.contains("unique source payload")); + assert!(repeated.contains("Source context is unchanged")); + } + + #[test] + fn accepted_patch_step_rotates_patch_thread() { + let mut request = request(); + request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Patch); + let first = thread_key(&request); + request.session.completed_steps.push("first patch".into()); + + assert_ne!(first, thread_key(&request)); + assert_eq!(thread_key(&request), "s_1:patch:1"); + } + + #[test] + fn retry_within_the_same_step_reuses_patch_thread() { + let mut request = request(); + request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Patch); + let first = thread_key(&request); + request.action = BackendAction::ContractRetry("repair it".into()); + + assert_eq!(first, thread_key(&request)); + } + + #[test] + fn why_uses_finding_schema_inside_the_goal_thread() { + let mut request = request(); + request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Finding); + request.card_contract.allow_goal_completion = true; + request.action = BackendAction::User(Action::Why); + let schema = schema::output_schema(&request); + + assert_eq!(thread_key(&request), "s_1:goal"); + assert_eq!(schema["properties"]["op"]["enum"][0], "finding"); + assert!(prompt(&request, true).contains("pending patch remains")); + } + + #[test] + fn goal_prompt_requests_one_verified_multi_file_batch() { + let mut request = request(); + request.card_contract.allow_goal_completion = true; + request.card_contract.expected_kind = None; + let prompt = prompt(&request, true); + + assert!(prompt.contains("Tool reads are valid patch source")); + assert!(prompt.contains("complete change in this turn")); + assert!(prompt.contains("Create missing files directly in the same batch")); + } +} diff --git a/rust/crates/loopbiotic_backends/src/codex_app/parse.rs b/rust/crates/loopbiotic_backends/src/codex_app/parse.rs new file mode 100644 index 0000000..06eb5eb --- /dev/null +++ b/rust/crates/loopbiotic_backends/src/codex_app/parse.rs @@ -0,0 +1,268 @@ +//! Parsing of the model's final answer into Loopbiotic cards, including the +//! structured-hunk patch format the output schema demands. + +use anyhow::{Result, anyhow}; +use loopbiotic_protocol::{Action, AgentOp, Card}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Deserialize)] +struct StructuredPatchOp { + op: String, + title: String, + explanation: String, + #[serde(default)] + goal_complete: bool, + patches: Vec, +} + +#[derive(Deserialize)] +struct StructuredFilePatch { + id: Option, + file: std::path::PathBuf, + explanation: String, + hunks: Vec, +} + +#[derive(Deserialize)] +struct StructuredHunk { + old_start: usize, + new_start: usize, + lines: Vec, +} + +#[derive(Deserialize)] +struct StructuredLine { + kind: StructuredLineKind, + text: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum StructuredLineKind { + Context, + Remove, + Add, +} + +pub(super) fn parse_card(output: &str, contract: &crate::CardContract) -> Result { + if contract.allow_goal_completion { + let value = serde_json::from_str::(output.trim())?; + if value.get("op").and_then(Value::as_str) == Some("patch") { + return parse_structured_patch(output); + } + } + if contract.expected_kind == Some(loopbiotic_protocol::CardKind::Patch) { + return parse_structured_patch(output); + } + + let op = serde_json::from_str::(output.trim())?; + + Ok(op.into_card("c_agent")) +} + +fn parse_structured_patch(output: &str) -> Result { + let op = serde_json::from_str::(output.trim())?; + if op.op != "patch" { + return Err(anyhow!("codex returned op {:?}, expected patch", op.op)); + } + + let patches = op + .patches + .into_iter() + .enumerate() + .map(|(index, patch)| { + Ok(loopbiotic_protocol::FilePatch { + id: patch.id.unwrap_or_else(|| format!("p_{}", index + 1)), + file: patch.file, + diff: render_structured_diff(&patch.hunks)?, + explanation: patch.explanation, + }) + }) + .collect::>>()?; + + Ok(Card::Patch(loopbiotic_protocol::PatchCard { + id: "c_agent".into(), + title: op.title, + explanation: op.explanation, + warnings: vec![], + goal_complete: op.goal_complete, + patches, + actions: vec![ + Action::Apply, + Action::Why, + Action::Retry, + Action::EditPrompt, + Action::Stop, + ], + })) +} + +fn render_structured_diff(hunks: &[StructuredHunk]) -> Result { + let mut diff = String::new(); + + for hunk in hunks { + let old_len = hunk + .lines + .iter() + .filter(|line| !matches!(line.kind, StructuredLineKind::Add)) + .count(); + let new_len = hunk + .lines + .iter() + .filter(|line| !matches!(line.kind, StructuredLineKind::Remove)) + .count(); + + if hunk.old_start == 0 || hunk.new_start == 0 { + return Err(anyhow!("structured patch line numbers must start at 1")); + } + if hunk.lines.is_empty() { + return Err(anyhow!("structured patch hunk has no lines")); + } + + diff.push_str(&format!( + "@@ -{},{} +{},{} @@\n", + hunk.old_start, old_len, hunk.new_start, new_len + )); + + for line in &hunk.lines { + if line.text.contains(['\n', '\r']) { + return Err(anyhow!("structured patch line contains a newline")); + } + + let prefix = match line.kind { + StructuredLineKind::Context => ' ', + StructuredLineKind::Remove => '-', + StructuredLineKind::Add => '+', + }; + diff.push(prefix); + diff.push_str(&line.text); + diff.push('\n'); + } + } + + Ok(diff) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn renders_typed_patch_hunks_as_unified_diff() { + let output = json!({ + "op": "patch", + "title": "Rename value", + "explanation": "Use the new name.", + "patches": [{ + "id": null, + "file": "src/main.rs", + "explanation": "Rename one line.", + "hunks": [{ + "old_start": 4, + "new_start": 4, + "lines": [ + {"kind": "context", "text": "fn main() {"}, + {"kind": "remove", "text": " let old = 1;"}, + {"kind": "add", "text": " let new = 1;"}, + {"kind": "context", "text": "}"} + ] + }] + }] + }); + + let Card::Patch(card) = parse_structured_patch(&output.to_string()).unwrap() else { + panic!("expected patch card"); + }; + + assert_eq!( + card.patches[0].diff, + "@@ -4,3 +4,3 @@\n fn main() {\n- let old = 1;\n+ let new = 1;\n }\n" + ); + } + + #[test] + fn goal_loop_dispatches_structured_patch_output() { + let output = json!({ + "op": "patch", + "title": "Continue the goal", + "explanation": "Apply the next accepted requirement.", + "goal_complete": true, + "patches": [{ + "id": null, + "file": "src/main.rs", + "explanation": "Update the next local block.", + "hunks": [{ + "old_start": 1, + "new_start": 1, + "lines": [ + {"kind": "remove", "text": "old"}, + {"kind": "add", "text": "new"} + ] + }] + }], + "claim": null, + "evidence": null, + "next": null, + "finding": null, + "location": null, + "annotation": null, + "question": null, + "options": null, + "reason": null, + "summary": null, + "changed_files": null, + "message": null + }); + let contract = crate::CardContract { + allow_goal_completion: true, + ..Default::default() + }; + + let Card::Patch(card) = parse_card(&output.to_string(), &contract).unwrap() else { + panic!("expected patch card"); + }; + assert!(card.goal_complete); + } + + #[test] + fn goal_loop_accepts_open_location_target_in_next() { + let output = json!({ + "op": "open_location", + "title": "Open inactive-account exception", + "reason": "Create the exception before referencing it.", + "location": null, + "next": { + "file": "src/Exception/OAuth/OAuthAccountNotActiveException.php", + "line": 1, + "column": 1, + "annotation": "New exception type." + } + }); + let contract = crate::CardContract { + allow_goal_completion: true, + ..Default::default() + }; + + let Card::OpenLocation(card) = parse_card(&output.to_string(), &contract).unwrap() else { + panic!("expected open_location card"); + }; + assert!( + card.location + .file + .ends_with("OAuthAccountNotActiveException.php") + ); + } + + #[test] + fn strict_parser_rejects_prose_around_json() { + let output = r#"Here is the result: {"op":"finding","title":"T","finding":"F","location":null,"annotation":null}"#; + let contract = crate::CardContract { + expected_kind: Some(loopbiotic_protocol::CardKind::Finding), + ..crate::CardContract::default() + }; + + assert!(parse_card(output, &contract).is_err()); + } +} diff --git a/rust/crates/loopbiotic_backends/src/codex_app/schema.rs b/rust/crates/loopbiotic_backends/src/codex_app/schema.rs new file mode 100644 index 0000000..6d621b7 --- /dev/null +++ b/rust/crates/loopbiotic_backends/src/codex_app/schema.rs @@ -0,0 +1,340 @@ +//! Hand-built JSON output schemas handed to the app-server so the model's +//! final answer is constrained to the card kind the turn demands. + +use serde_json::{Value, json}; + +use crate::BackendRequest; + +pub(super) fn output_schema(req: &BackendRequest) -> Value { + if req.card_contract.allow_goal_completion + && req.card_contract.expected_kind == Some(loopbiotic_protocol::CardKind::Finding) + { + return finding_schema(); + } + if req.card_contract.allow_goal_completion { + return goal_loop_schema(&req.card_contract); + } + + match req.card_contract.expected_kind { + Some(loopbiotic_protocol::CardKind::Patch) => patch_schema(&req.card_contract), + Some(loopbiotic_protocol::CardKind::Hypothesis) => hypothesis_schema(), + Some(loopbiotic_protocol::CardKind::Finding) => finding_schema(), + Some(loopbiotic_protocol::CardKind::Choice) => choice_schema(), + Some(loopbiotic_protocol::CardKind::Deny) => deny_schema(), + Some(loopbiotic_protocol::CardKind::Summary) => summary_schema(), + Some(loopbiotic_protocol::CardKind::Error) => error_schema(), + Some(loopbiotic_protocol::CardKind::OpenLocation) | None => any_op_schema(), + } +} + +/// Schema for turns without a demanded kind: the agent picks whichever op +/// fits, including a clarifying choice or a deny. Mirrors +/// schemas/loopbiotic-agent-op.schema.json (every field present, unused ones null). +fn any_op_schema() -> Value { + object_schema( + &[ + "op", + "title", + "claim", + "evidence", + "next", + "finding", + "location", + "annotation", + "explanation", + "goal_complete", + "patches", + "question", + "options", + "reason", + "summary", + "changed_files", + "message", + ], + json!({ + "op": {"type": "string", "enum": ["hypothesis", "finding", "patch", "choice", "deny", "open_location", "summary", "error"]}, + "title": {"type": "string"}, + "claim": {"type": ["string", "null"]}, + "evidence": nullable_location_schema(), + "next": nullable_location_schema(), + "finding": {"type": ["string", "null"]}, + "location": nullable_location_schema(), + "annotation": {"type": ["string", "null"]}, + "explanation": {"type": ["string", "null"]}, + "goal_complete": {"type": ["boolean", "null"]}, + "patches": { + "type": ["array", "null"], + "items": object_schema( + &["id", "file", "diff", "explanation"], + json!({ + "id": {"type": ["string", "null"]}, + "file": {"type": "string"}, + "diff": {"type": "string"}, + "explanation": {"type": "string"} + }) + ) + }, + "question": {"type": ["string", "null"]}, + "options": { + "type": ["array", "null"], + "items": object_schema( + &["id", "label", "action"], + json!({ + "id": {"type": "string"}, + "label": {"type": "string"}, + "action": { + "type": "string", + "enum": ["follow", "why", "fix", "other_lead", "retry", "edit_prompt", "open", "run_check", "next", "stop"] + } + }) + ) + }, + "reason": {"type": ["string", "null"]}, + "summary": {"type": ["string", "null"]}, + "changed_files": {"type": ["array", "null"], "items": {"type": "string"}}, + "message": {"type": ["string", "null"]} + }), + ) +} + +fn goal_loop_schema(contract: &crate::CardContract) -> Value { + let mut schema = any_op_schema(); + schema["properties"]["op"]["enum"] = json!([ + "patch", + "choice", + "deny", + "open_location", + "summary", + "error" + ]); + let mut patches = patch_schema(contract)["properties"]["patches"].clone(); + patches["type"] = json!(["array", "null"]); + schema["properties"]["patches"] = patches; + schema +} + +fn object_schema(required: &[&str], properties: Value) -> Value { + json!({ + "type": "object", + "required": required, + "properties": properties, + "additionalProperties": false + }) +} + +fn nullable_location_schema() -> Value { + json!({ + "anyOf": [ + object_schema( + &["file", "line", "column", "annotation"], + json!({ + "file": {"type": "string"}, + "line": {"type": "integer"}, + "column": {"type": "integer"}, + "annotation": {"type": ["string", "null"]} + }) + ), + {"type": "null"} + ] + }) +} + +fn location_schema() -> Value { + object_schema( + &["file", "line", "column", "annotation"], + json!({ + "file": {"type": "string"}, + "line": {"type": "integer", "minimum": 1}, + "column": {"type": "integer", "minimum": 1}, + "annotation": {"type": ["string", "null"]} + }), + ) +} + +fn hypothesis_schema() -> Value { + object_schema( + &["op", "title", "claim", "evidence", "next"], + json!({ + "op": {"type": "string", "enum": ["hypothesis"]}, + "title": {"type": "string"}, + "claim": {"type": "string"}, + "evidence": nullable_location_schema(), + "next": location_schema() + }), + ) +} + +fn finding_schema() -> Value { + object_schema( + &["op", "title", "finding", "location", "annotation"], + json!({ + "op": {"type": "string", "enum": ["finding"]}, + "title": {"type": "string"}, + "finding": {"type": "string"}, + "location": location_schema(), + "annotation": {"type": ["string", "null"]} + }), + ) +} + +fn patch_schema(contract: &crate::CardContract) -> Value { + object_schema( + &["op", "title", "explanation", "goal_complete", "patches"], + json!({ + "op": {"type": "string", "enum": ["patch"]}, + "title": {"type": "string"}, + "explanation": {"type": "string"}, + "goal_complete": {"type": "boolean"}, + "patches": { + "type": "array", + "minItems": 1, + "maxItems": contract.max_patch_files, + "items": object_schema( + &["id", "file", "explanation", "hunks"], + json!({ + "id": {"type": ["string", "null"]}, + "file": {"type": "string"}, + "explanation": {"type": "string"}, + "hunks": { + "type": "array", + "minItems": 1, + "maxItems": contract.max_hunks_per_patch, + "items": object_schema( + &["old_start", "new_start", "lines"], + json!({ + "old_start": {"type": "integer", "minimum": 1}, + "new_start": {"type": "integer", "minimum": 1}, + "lines": { + "type": "array", + "minItems": 1, + "maxItems": contract.max_changed_lines + 8, + "items": object_schema( + &["kind", "text"], + json!({ + "kind": {"type": "string", "enum": ["context", "remove", "add"]}, + "text": {"type": "string"} + }) + ) + } + }) + ) + } + }) + ) + } + }), + ) +} + +fn choice_schema() -> Value { + object_schema( + &["op", "title", "question", "options"], + json!({ + "op": {"type": "string", "enum": ["choice"]}, + "title": {"type": "string"}, + "question": {"type": "string"}, + "options": { + "type": "array", + "items": object_schema( + &["id", "label", "action"], + json!({ + "id": {"type": "string"}, + "label": {"type": "string"}, + "action": { + "type": "string", + "enum": ["follow", "why", "fix", "other_lead", "retry", "edit_prompt", "open", "run_check", "next", "stop"] + } + }) + ) + } + }), + ) +} + +fn summary_schema() -> Value { + object_schema( + &["op", "title", "summary", "changed_files"], + json!({ + "op": {"type": "string", "enum": ["summary"]}, + "title": {"type": "string"}, + "summary": {"type": "string"}, + "changed_files": {"type": "array", "items": {"type": "string"}} + }), + ) +} + +fn deny_schema() -> Value { + object_schema( + &["op", "title", "reason", "location"], + json!({ + "op": {"type": "string", "enum": ["deny"]}, + "title": {"type": "string"}, + "reason": {"type": "string"}, + "location": nullable_location_schema() + }), + ) +} + +fn error_schema() -> Value { + object_schema( + &["op", "title", "message"], + json!({ + "op": {"type": "string", "enum": ["error"]}, + "title": {"type": "string"}, + "message": {"type": "string"} + }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn patch_schema_exposes_hunks_instead_of_raw_diff() { + let schema = patch_schema(&crate::CardContract::default()); + let patch = &schema["properties"]["patches"]["items"]; + + assert!(patch["properties"].get("diff").is_none()); + assert_eq!(patch["properties"]["hunks"]["type"], "array"); + assert_eq!(schema["properties"]["patches"]["maxItems"], 1); + assert_eq!(patch["properties"]["hunks"]["maxItems"], 1); + } + + #[test] + fn goal_loop_schema_allows_structured_patch_or_summary() { + let contract = crate::CardContract { + max_patch_files: loopbiotic_protocol::MAX_GOAL_PATCH_FILES, + max_hunks_per_patch: loopbiotic_protocol::MAX_GOAL_HUNKS_PER_PATCH, + max_changed_lines: loopbiotic_protocol::MAX_GOAL_CHANGED_LINES, + ..Default::default() + }; + let schema = goal_loop_schema(&contract); + let ops = schema["properties"]["op"]["enum"].as_array().unwrap(); + + assert!(ops.contains(&json!("patch"))); + assert!(ops.contains(&json!("summary"))); + assert!(!ops.contains(&json!("finding"))); + assert_eq!( + schema["properties"]["patches"]["maxItems"], + loopbiotic_protocol::MAX_GOAL_PATCH_FILES + ); + assert!(schema["properties"]["patches"]["items"]["properties"]["hunks"].is_object()); + assert_eq!( + schema["properties"]["patches"]["items"]["properties"]["hunks"]["maxItems"], + loopbiotic_protocol::MAX_GOAL_HUNKS_PER_PATCH + ); + assert!(schema["properties"]["goal_complete"].is_object()); + } + + #[test] + fn discovery_schema_requires_a_concrete_next_location() { + let schema = hypothesis_schema(); + + assert_eq!(schema["properties"]["next"]["type"], "object"); + assert_eq!( + schema["properties"]["next"]["properties"]["line"]["minimum"], + 1 + ); + } +} diff --git a/rust/crates/loopbiotic_backends/src/codex_app/transport.rs b/rust/crates/loopbiotic_backends/src/codex_app/transport.rs new file mode 100644 index 0000000..c59905b --- /dev/null +++ b/rust/crates/loopbiotic_backends/src/codex_app/transport.rs @@ -0,0 +1,469 @@ +//! JSON-RPC transport to a single `codex app-server` process: request/response +//! correlation, turn streaming, and the server-initiated requests Loopbiotic +//! declines on the model's behalf. + +use std::collections::HashMap; + +use anyhow::{Result, anyhow}; +use loopbiotic_protocol::TokenUsage; +use serde_json::{Value, json}; +use tokio::io::{AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStdin, ChildStdout}; + +use crate::ProgressReporter; +use crate::support::report_progress; + +use super::debug; + +pub(super) struct CodexAppState { + pub(super) process: Option, + next_id: u64, + pub(super) threads: HashMap, + pub(super) context_fingerprints: HashMap, +} + +pub(super) struct CodexAppProcess { + pub(super) child: Child, + pub(super) stdin: ChildStdin, + pub(super) stdout: Lines>, +} + +#[derive(Debug)] +pub(super) struct TurnOutput { + pub(super) text: String, + pub(super) token_usage: Option, + pub(super) activities: Vec, +} + +impl Drop for CodexAppProcess { + fn drop(&mut self) { + let _ = self.child.start_kill(); + } +} + +impl Default for CodexAppState { + fn default() -> Self { + Self { + process: None, + next_id: 1, + threads: HashMap::new(), + context_fingerprints: HashMap::new(), + } + } +} + +impl CodexAppState { + pub(super) fn clear_conversation(&mut self) { + self.threads.clear(); + self.context_fingerprints.clear(); + } + + pub(super) fn invalidate_process(&mut self) { + self.process = None; + self.clear_conversation(); + } + + /// Kills a wedged app-server and forgets everything about it so the next + /// turn spawns a fresh process with full context. + pub(super) fn kill_process(&mut self) { + if let Some(process) = self.process.as_mut() { + let _ = process.child.start_kill(); + } + self.invalidate_process(); + } + + fn next_request_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id += 1; + id + } + + pub(super) async fn request(&mut self, mut request: Value) -> Result { + let id = self.next_request_id(); + request["id"] = json!(id); + + let line = serde_json::to_string(&request)?; + self.send_line(&line).await?; + + loop { + let message = self.next_message().await?; + + if self.handle_server_request(&message).await? { + continue; + } + + if message.get("id").and_then(Value::as_u64) != Some(id) { + continue; + } + + if let Some(error) = message.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("codex app-server request failed"); + + return Err(anyhow!(message.to_string())); + } + + return Ok(message.get("result").cloned().unwrap_or(Value::Null)); + } + } + + pub(super) async fn read_turn( + &mut self, + turn_id: &str, + session_id: &str, + progress: Option<&ProgressReporter>, + ) -> Result { + let mut text = String::new(); + let mut token_usage = None; + let mut activities = Vec::new(); + + loop { + let message = self.next_message().await?; + + if self.handle_server_request(&message).await? { + continue; + } + + let method = message.get("method").and_then(Value::as_str); + let params = message.get("params").unwrap_or(&Value::Null); + let message_turn_id = message_turn_id(params); + + if let Some((phase, label)) = progress_for_message(&message, turn_id) { + report_progress(progress, session_id, phase, label); + } + + if method == Some("item/completed") + && message_turn_id == Some(turn_id) + && let Some(item) = params.get("item") + && item.get("type").and_then(Value::as_str) == Some("agentMessage") + && item.get("phase").and_then(Value::as_str) == Some("final_answer") + && let Some(value) = item.get("text").and_then(Value::as_str) + { + text = value.to_string(); + } + + if method == Some("item/completed") + && message_turn_id == Some(turn_id) + && let Some(item) = params.get("item") + && let Some(activity) = activity_summary(item) + && !activities.contains(&activity) + { + activities.push(activity); + } + + if method == Some("thread/tokenUsage/updated") + && message_turn_id == Some(turn_id) + && let Some(usage) = parse_usage(params.get("tokenUsage")) + { + token_usage = Some(usage); + } + + if method == Some("turn/completed") && message_turn_id == Some(turn_id) { + debug("codex turn completed"); + if let Some(error) = params + .get("turn") + .and_then(|turn| turn.get("error")) + .filter(|error| !error.is_null()) + { + return Err(anyhow!("codex turn failed: {error}")); + } + + if text.trim().is_empty() { + return Err(anyhow!("codex turn completed without final answer")); + } + + return Ok(TurnOutput { + text, + token_usage, + activities, + }); + } + } + } + + async fn next_message(&mut self) -> Result { + loop { + let result = { + let process = self + .process + .as_mut() + .ok_or_else(|| anyhow!("codex app-server process unavailable"))?; + process.stdout.next_line().await + }; + let line = match result { + Ok(Some(line)) => line, + Ok(None) => { + self.invalidate_process(); + return Err(anyhow!("codex app-server closed stdout")); + } + Err(error) => { + self.invalidate_process(); + return Err(error.into()); + } + }; + + if line.trim().is_empty() { + continue; + } + + return Ok(serde_json::from_str(&line)?); + } + } + + async fn send_line(&mut self, line: &str) -> Result<()> { + let result = async { + let process = self + .process + .as_mut() + .ok_or_else(|| anyhow!("codex app-server process unavailable"))?; + process.stdin.write_all(line.as_bytes()).await?; + process.stdin.write_all(b"\n").await?; + process.stdin.flush().await?; + + Ok(()) + } + .await; + + if result.is_err() { + self.invalidate_process(); + } + + result + } + + async fn handle_server_request(&mut self, message: &Value) -> Result { + let Some(id) = message.get("id").cloned() else { + return Ok(false); + }; + let Some(method) = message.get("method").and_then(Value::as_str) else { + return Ok(false); + }; + + let response = match method { + "item/commandExecution/requestApproval" | "execCommandApproval" => { + json!({"id": id, "result": {"decision": "decline"}}) + } + "item/fileChange/requestApproval" | "applyPatchApproval" => { + json!({"id": id, "result": {"decision": "decline"}}) + } + "item/permissions/requestApproval" => { + json!({"id": id, "result": {"permissions": {}, "scope": "turn", "strictAutoReview": true}}) + } + "item/tool/call" => { + json!({"id": id, "result": {"contentItems": [], "success": false}}) + } + "item/tool/requestUserInput" => json!({"id": id, "result": {"answers": {}}}), + "mcpServer/elicitation/request" => { + json!({"id": id, "result": {"action": "decline", "content": null, "_meta": null}}) + } + "account/chatgptAuthTokens/refresh" | "attestation/generate" => { + json!({"id": id, "error": {"code": -32603, "message": "Loopbiotic does not handle this app-server request"}}) + } + _ => return Ok(false), + }; + + debug(&format!("handled codex server request {method}")); + + let line = serde_json::to_string(&response)?; + self.send_line(&line).await?; + + Ok(true) + } +} + +fn message_turn_id(params: &Value) -> Option<&str> { + params.get("turnId").and_then(Value::as_str).or_else(|| { + params + .get("turn") + .and_then(|turn| turn.get("id")) + .and_then(Value::as_str) + }) +} + +/// Extracts token usage from the app-server's `thread/tokenUsage/updated` +/// notification. This wire format is specific to the Codex app-server; the +/// Claude adapter parses the Anthropic API's usage counters instead. +fn parse_usage(value: Option<&Value>) -> Option { + let last = value?.get("last")?; + let input = last.get("inputTokens")?.as_u64()? as usize; + let cached_input = last + .get("cachedInputTokens") + .and_then(Value::as_u64) + .unwrap_or_default() as usize; + let output = last.get("outputTokens")?.as_u64()? as usize; + let total = last.get("totalTokens")?.as_u64()? as usize; + + Some(TokenUsage { + input_tokens: input, + cached_input_tokens: cached_input, + output_tokens: output, + total_tokens: total, + estimated: false, + }) +} + +fn activity_summary(item: &Value) -> Option { + let kind = item.get("type").and_then(Value::as_str)?; + if matches!(kind, "reasoning" | "agentMessage" | "plan") { + return None; + } + + let detail = match kind { + "commandExecution" => item.get("command").map(compact_value), + "fileChange" => item + .get("path") + .or_else(|| item.get("changes")) + .map(compact_value), + "mcpToolCall" => { + let server = item.get("server").and_then(Value::as_str).unwrap_or("mcp"); + let tool = item + .get("tool") + .or_else(|| item.get("name")) + .and_then(Value::as_str) + .unwrap_or("tool"); + Some(format!("{server}/{tool}")) + } + "webSearch" => item.get("query").map(compact_value), + "dynamicToolCall" | "toolCall" => item + .get("tool") + .or_else(|| item.get("name")) + .map(compact_value), + _ if kind.to_lowercase().contains("tool") || kind.to_lowercase().contains("command") => { + item.get("name").map(compact_value) + } + _ => return None, + }; + let detail = detail.filter(|value| !value.is_empty()); + Some(match detail { + Some(detail) => format!("{kind}: {detail}"), + None => kind.to_string(), + }) +} + +fn compact_value(value: &Value) -> String { + let value = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()); + let mut compact = value.split_whitespace().collect::>().join(" "); + if compact.chars().count() > 240 { + compact = compact.chars().take(240).collect::(); + compact.push_str("..."); + } + compact +} + +fn progress_for_message(message: &Value, turn_id: &str) -> Option<(&'static str, &'static str)> { + let params = message.get("params")?; + + if message_turn_id(params) != Some(turn_id) { + return None; + } + + match message.get("method").and_then(Value::as_str) { + Some("turn/started") => Some(("working", "Codex is processing the request")), + Some("item/started") => match params + .get("item") + .and_then(|item| item.get("type")) + .and_then(Value::as_str) + { + Some("reasoning") => Some(("reviewing", "Codex is reviewing the provided context")), + Some("agentMessage") => Some(("responding", "Codex is preparing a response")), + _ => Some(("working", "Codex is processing the request")), + }, + Some("item/completed") + if params + .get("item") + .and_then(|item| item.get("type")) + .and_then(Value::as_str) + == Some("agentMessage") => + { + Some(("validating", "Codex is validating the response")) + } + Some("turn/completed") => Some(("finishing", "Codex completed the response")), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalidating_a_process_discards_ephemeral_thread_state() { + let mut state = CodexAppState::default(); + state.threads.insert("key".into(), "thread".into()); + state.context_fingerprints.insert("thread".into(), 42); + + state.invalidate_process(); + + assert!(state.process.is_none()); + assert!(state.threads.is_empty()); + assert!(state.context_fingerprints.is_empty()); + } + + #[test] + fn summarizes_tool_activity_without_reasoning_text() { + let command = json!({ + "type": "commandExecution", + "command": "rg layout_editor.html templates" + }); + let reasoning = json!({"type": "reasoning", "text": "private"}); + + assert!( + activity_summary(&command) + .unwrap() + .contains("layout_editor.html") + ); + assert_eq!(activity_summary(&reasoning), None); + } + + #[test] + fn parses_usage_from_app_server_notification() { + let value = json!({ + "last": { + "inputTokens": 10, + "cachedInputTokens": 8, + "outputTokens": 5, + "totalTokens": 15 + } + }); + let usage = parse_usage(Some(&value)).unwrap(); + + assert_eq!(usage.input_tokens, 10); + assert_eq!(usage.cached_input_tokens, 8); + assert_eq!(usage.output_tokens, 5); + assert!(!usage.estimated); + } + + #[test] + fn normalizes_progress_without_exposing_agent_text() { + let event = json!({ + "method": "item/started", + "params": { + "turnId": "turn_1", + "item": { + "type": "reasoning", + "text": "private model reasoning" + } + } + }); + + assert_eq!( + progress_for_message(&event, "turn_1"), + Some(("reviewing", "Codex is reviewing the provided context")) + ); + } + + #[test] + fn ignores_progress_for_another_turn() { + let event = json!({ + "method": "turn/completed", + "params": {"turnId": "turn_1"} + }); + + assert_eq!(progress_for_message(&event, "turn_2"), None); + } +} From bc265d790e76708799452b564575fed0a00c6ebb Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 14:15:07 +0200 Subject: [PATCH 18/37] fix: bound Ollama HTTP requests with the shared turn deadline --- rust/crates/loopbiotic_backends/src/ollama.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rust/crates/loopbiotic_backends/src/ollama.rs b/rust/crates/loopbiotic_backends/src/ollama.rs index e3be0d3..714a64d 100644 --- a/rust/crates/loopbiotic_backends/src/ollama.rs +++ b/rust/crates/loopbiotic_backends/src/ollama.rs @@ -4,7 +4,7 @@ use loopbiotic_protocol::{BackendInfo, Card, TokenUsage}; use serde::Deserialize; use serde_json::json; -use crate::support::{error_card, optional_env, report_progress}; +use crate::support::{error_card, optional_env, report_progress, turn_timeout_from_env}; use crate::{ BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, ProgressReporter, enforce_card_contract, estimate_tokens, @@ -51,11 +51,17 @@ impl OllamaBackend { model: impl Into, keep_alive: impl Into, ) -> Self { + // There is no child process to kill here, so a hung Ollama server is + // bounded by the same turn deadline the process backends use. + let mut builder = reqwest::Client::builder(); + if let Some(limit) = turn_timeout_from_env() { + builder = builder.timeout(limit); + } Self { host: host.into().trim_end_matches('/').to_string(), model: model.into(), keep_alive: keep_alive.into(), - client: reqwest::Client::new(), + client: builder.build().unwrap_or_else(|_| reqwest::Client::new()), } } From 9f2d6d2b6cc14d1c05f6745b05e830211d85d32f Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 14:15:07 +0200 Subject: [PATCH 19/37] ci: lint Lua, run unit tests and a real daemon round-trip --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91493e4..e6c2360 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,16 @@ jobs: - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace --no-fail-fast + lua-lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: taiki-e/install-action@v2 + with: + tool: stylua,selene + - run: stylua --check lua/ tests/ scripts/headless-smoke.lua + - run: selene lua/ + neovim: runs-on: ubuntu-24.04 steps: @@ -30,9 +40,22 @@ jobs: with: version: stable neovim: true + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run Lua unit tests + run: nvim --headless -u NONE -i NONE -l tests/lua/run.lua - name: Run headless smoke test env: XDG_STATE_HOME: ${{ runner.temp }}/state XDG_DATA_HOME: ${{ runner.temp }}/data XDG_CACHE_HOME: ${{ runner.temp }}/cache run: nvim --headless -u NONE -i NONE -l scripts/headless-smoke.lua + - name: Build loopbioticd + run: cargo build -p loopbioticd + - name: Run real daemon round-trip + env: + XDG_STATE_HOME: ${{ runner.temp }}/state + XDG_DATA_HOME: ${{ runner.temp }}/data + XDG_CACHE_HOME: ${{ runner.temp }}/cache + LOOPBIOTIC_SMOKE_BIN: target/debug/loopbioticd + run: nvim --headless -u NONE -i NONE -l scripts/headless-smoke.lua From 904cdd5c3c3c4c62efef9803f53627151f4a3cb9 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 14:15:07 +0200 Subject: [PATCH 20/37] docs: changelog for the desloppification round --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79cc108..0d233f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ The project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- Backend turns now run under a configurable deadline + (`LOOPBIOTIC_TURN_TIMEOUT_SECS`, default 600, `0` disables). A wedged agent + CLI is killed and respawned on the next turn instead of hanging the session + forever; the Ollama HTTP client uses the same deadline. +- Stopping or switching the backend now fails in-flight requests instead of + dropping their callbacks, so the thinking spinner can no longer get stuck. +- RPC sends to an already-exited backend are dropped and logged instead of + throwing inside scheduled callbacks. +- Managed `loopbioticd` downloads now have connect and transfer time limits, + and only the expected binary is extracted from the release archive. +- Retry exhaustion and goal-batch shape mismatches in the session harness + degrade to error cards instead of panicking the daemon. +- Push CI runs again: the workflow triggered on a nonexistent `main` branch. + +### Changed + +- `initialize` validates the client protocol version when one is supplied and + returns a structured error (`-32001`) on mismatch instead of failing later + with cryptic errors. +- The project context scan no longer blocks async worker threads. +- Internal restructuring: `engine.rs`, `codex_app.rs`, and the context crate + are split into focused modules; duplicated backend and Lua helpers are + shared; Lua state reset is defined next to the state it resets. + +### Added + +- Lua tooling (`stylua`, `selene`, LuaLS config) enforced in CI, headless Lua + unit tests for the patch engine and session state, `loopbioticd` JSON-RPC + integration tests, session state-machine transition tests, and a real + daemon round-trip smoke test. + ## [0.3.2] - 2026-07-14 ### Added From ab8f537623c86466bcbf1776e65df0cca6937552 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 14:21:31 +0200 Subject: [PATCH 21/37] fix: clamp cursor targets to the buffer to survive append-at-EOF drafts --- CHANGELOG.md | 4 ++++ lua/loopbiotic/init.lua | 4 +++- lua/loopbiotic/navigation.lua | 12 +++++----- lua/loopbiotic/util.lua | 15 +++++++++++++ tests/lua/test_navigation.lua | 42 +++++++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 tests/lua/test_navigation.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d233f8..ffe304f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The project follows [Semantic Versioning](https://semver.org/). ### Fixed +- Navigating to a card or draft no longer throws "Cursor position outside + buffer" when the target line does not exist yet — for example a patch hunk + that appends to the end of a short file, or an agent-supplied location past + the end of the buffer. Cursor targets are now clamped to the real buffer. - Backend turns now run under a configurable deadline (`LOOPBIOTIC_TURN_TIMEOUT_SECS`, default 600, `0` disables). A wedged agent CLI is killed and respawned on the next turn instead of hanging the session diff --git a/lua/loopbiotic/init.lua b/lua/loopbiotic/init.lua index 1c05767..d1f7b28 100644 --- a/lua/loopbiotic/init.lua +++ b/lua/loopbiotic/init.lua @@ -419,8 +419,10 @@ function M.go_to() if state.source_buf and vim.api.nvim_buf_is_valid(state.source_buf) then local win = context.buffer_window(state.source_buf) if win then + local cursor = state.source_cursor or { 1, 0 } vim.api.nvim_set_current_win(win) - vim.api.nvim_win_set_cursor(win, state.source_cursor or { 1, 0 }) + -- The buffer may have shrunk since the position was captured. + vim.api.nvim_win_set_cursor(win, util.clamp_cursor(state.source_buf, cursor[1], cursor[2])) vim.cmd("normal! zz") return end diff --git a/lua/loopbiotic/navigation.lua b/lua/loopbiotic/navigation.lua index 81ae508..f1b96d2 100644 --- a/lua/loopbiotic/navigation.lua +++ b/lua/loopbiotic/navigation.lua @@ -85,13 +85,13 @@ function M.open_location(location) vim.cmd("edit " .. vim.fn.fnameescape(file)) end - local line = location.line or 1 - local column = location.column or 1 + local buf = vim.api.nvim_get_current_buf() + local pos = util.clamp_cursor(buf, location.line, (location.column or 1) - 1) - vim.api.nvim_win_set_cursor(0, { line, math.max(column - 1, 0) }) - state.source_buf = vim.api.nvim_get_current_buf() - state.source_cursor = { line, math.max(column - 1, 0) } - extmarks.annotate(0, line, location.annotation) + vim.api.nvim_win_set_cursor(0, pos) + state.source_buf = buf + state.source_cursor = pos + extmarks.annotate(0, pos[1], location.annotation) vim.cmd("normal! zz") return true diff --git a/lua/loopbiotic/util.lua b/lua/loopbiotic/util.lua index 7a904b0..06f7776 100644 --- a/lua/loopbiotic/util.lua +++ b/lua/loopbiotic/util.lua @@ -31,6 +31,21 @@ function M.observation_node(observation, index) return string.format("[%s%d%s%s]", kind, index, active, repeats) end +-- Clamp a 1-indexed line / 0-indexed column pair to positions that exist in +-- buf, so it is always safe to pass to nvim_win_set_cursor. Card locations +-- come from the agent and draft cursors are computed against post-apply +-- content, so both can point past the end of the real buffer (for example a +-- hunk that appends to a one-line barrel file). +---@param buf integer +---@param line integer|nil +---@param column integer|nil +---@return integer[] pos { line, column } +function M.clamp_cursor(buf, line, column) + local count = vim.api.nvim_buf_line_count(buf) + + return { math.min(math.max(line or 1, 1), math.max(count, 1)), math.max(column or 0, 0) } +end + -- Whether file lies inside root (default: the current working directory). -- Symlinks are resolved when the paths exist. ---@param file string|nil diff --git a/tests/lua/test_navigation.lua b/tests/lua/test_navigation.lua new file mode 100644 index 0000000..ece2807 --- /dev/null +++ b/tests/lua/test_navigation.lua @@ -0,0 +1,42 @@ +-- Cursor targets can point past the end of the real buffer: card locations +-- come from the agent, and draft cursors are computed against post-apply +-- content (e.g. a hunk appending to a one-line barrel index.ts). +local util = require("loopbiotic.util") +local navigation = require("loopbiotic.navigation") + +return function(t) + t.test("clamp_cursor keeps valid positions and floors the column", function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "one", "two", "three" }) + + t.eq(util.clamp_cursor(buf, 2, 1), { 2, 1 }) + t.eq(util.clamp_cursor(buf, nil, nil), { 1, 0 }) + t.eq(util.clamp_cursor(buf, 0, -5), { 1, 0 }) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + t.test("clamp_cursor bounds lines past the end of the buffer", function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "export * from './lib/thing';" }) + + t.eq(util.clamp_cursor(buf, 2, 0), { 1, 0 }) + t.eq(util.clamp_cursor(buf, 500, 3), { 1, 3 }) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + t.test("open_location survives a line past the end of a short file", function() + local file = vim.fn.tempname() .. ".ts" + vim.fn.writefile({ "export * from './lib/ui-icon-header/ui-icon-header.component';" }, file) + + -- Line 2 of a one-line file: the first added line of an appending draft. + local ok = navigation.open_location({ file = file, line = 2, column = 1 }) + + t.eq(ok, true) + t.eq(vim.api.nvim_win_get_cursor(0)[1], 1) + + vim.cmd("bwipeout!") + vim.fn.delete(file) + end) +end From f413634379e00403ba543ae0cac1f0947e6ef9dc Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 14:45:35 +0200 Subject: [PATCH 22/37] feat: report backend identity and resolved model at warmup (protocol 9) --- lua/loopbiotic/version.lua | 2 +- .../loopbiotic_backends/src/claude_app.rs | 169 +++++++++++++++++- .../loopbiotic_backends/src/codex_app/mod.rs | 27 ++- rust/crates/loopbiotic_backends/src/lib.rs | 47 +++++ rust/crates/loopbiotic_backends/src/mock.rs | 14 +- rust/crates/loopbiotic_backends/src/ollama.rs | 91 +++++++++- rust/crates/loopbiotic_protocol/src/lib.rs | 2 +- rust/crates/loopbioticd/src/main.rs | 2 +- rust/crates/loopbioticd/tests/rpc.rs | 23 +++ 9 files changed, 365 insertions(+), 12 deletions(-) diff --git a/lua/loopbiotic/version.lua b/lua/loopbiotic/version.lua index a0d1502..6929aa8 100644 --- a/lua/loopbiotic/version.lua +++ b/lua/loopbiotic/version.lua @@ -1,4 +1,4 @@ return { plugin = "0.3.2", - protocol = 8, + protocol = 9, } diff --git a/rust/crates/loopbiotic_backends/src/claude_app.rs b/rust/crates/loopbiotic_backends/src/claude_app.rs index 0a74a00..55d558f 100644 --- a/rust/crates/loopbiotic_backends/src/claude_app.rs +++ b/rust/crates/loopbiotic_backends/src/claude_app.rs @@ -16,8 +16,8 @@ use crate::support::{ optional_env, report_progress, turn_phase, turn_timeout_from_env, }; use crate::{ - BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, ProgressReporter, - enforce_card_contract, estimate_tokens, + BackendAdapter, BackendIdentity, BackendMetadata, BackendRequest, BackendResponse, + ProgressReporter, enforce_card_contract, estimate_tokens, }; const SYSTEM_PROMPT: &str = r#"You are a local Loopbiotic pair-programming partner inside the user's editor. @@ -66,6 +66,9 @@ struct ClaudeAppState { // Pre-spawned discovery process created by warmup() before a session // exists, adopted by the next session's first discovery turn. warm: Option>>, + // Latest model the CLI reported (init event or turn result); survives + // slot turnover so identity() can answer without respawning. + last_reported_model: Option, } #[derive(Default)] @@ -278,6 +281,17 @@ impl ClaudeAppBackend { } } + /// The configured model as one identity string: the discovery model, or + /// "discovery,patch" when the two phases run different models. + fn configured_model(&self) -> Option { + let discovery = self.phase_model(Phase::Discovery)?; + + match self.phase_model(Phase::Patch) { + Some(patch) if patch != discovery => Some(format!("{discovery},{patch}")), + _ => Some(discovery), + } + } + fn phase_thinking(&self, phase: Phase) -> Option { match phase { // Patch turns keep the CLI's adaptive thinking: diff correctness @@ -374,6 +388,32 @@ impl ClaudeAppBackend { Ok(()) } + /// Resolves the CLI's own default model when none is configured: the + /// latest model a turn or init event reported, else the init event of the + /// warm discovery process (spawning it warmup-style when needed). + async fn discovered_default_model(&self) -> Option { + if let Some(model) = self.state.lock().await.last_reported_model.clone() { + return Some(model); + } + + self.warm_up().await.ok()?; + let warm = self.state.lock().await.warm.clone()?; + let mut slot = warm.lock().await; + if slot.reported_model.is_none() { + // The warm process has not run a turn yet, so its init event is + // still unread on stdout. + slot.reported_model = read_init_model(&mut slot).await; + } + let model = slot.reported_model.clone(); + drop(slot); + + if let Some(model) = model.as_ref() { + self.state.lock().await.last_reported_model = Some(model.clone()); + } + + model + } + /// Returns the slot for this turn's phase, creating it (or adopting the /// warm process) as needed. The outer state lock is held only for the /// bookkeeping; the returned slot's own lock serializes the actual turn, @@ -563,6 +603,31 @@ impl ClaudeAppBackend { } } +/// Reads the freshly spawned CLI's init/system event, which names the model +/// the process will use. Bounded by a short deadline so identity() can never +/// hang on a wedged CLI; only called before the process's first turn. +async fn read_init_model(slot: &mut ClaudeSlot) -> Option { + const INIT_TIMEOUT: Duration = Duration::from_secs(5); + + let process = slot.process.as_mut()?; + let init = async { + while let Ok(Some(line)) = process.stdout.next_line().await { + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if let StreamEvent::Init(model) = parse_stream_event(&value) { + return model; + } + } + None + }; + + tokio::time::timeout(INIT_TIMEOUT, init) + .await + .ok() + .flatten() +} + async fn send_turn(slot: &mut ClaudeSlot, prompt: &str) -> Result<()> { let message = json!({ "type": "user", @@ -596,6 +661,9 @@ impl BackendAdapter for ClaudeAppBackend { progress: Option, ) -> Result { let output = self.ask(&req, progress.as_ref()).await?; + if let Some(model) = output.model.as_ref() { + self.state.lock().await.last_reported_model = Some(model.clone()); + } let card = crate::parse_card(&output.text).unwrap_or_else(|error| { Self::error_card(format!("{error}\n\nRaw output:\n{}", output.text)) }); @@ -624,6 +692,20 @@ impl BackendAdapter for ClaudeAppBackend { self.warm_up().await } + async fn identity(&self) -> BackendIdentity { + let model = match self.configured_model() { + Some(model) => Some(model), + None => self.discovered_default_model().await, + }; + + BackendIdentity { + backend: "claude_app".into(), + model, + // The claude CLI has no model-listing API. + models: vec![], + } + } + fn capabilities(&self) -> BackendInfo { BackendInfo { name: "claude_app".into(), @@ -944,6 +1026,89 @@ mod tests { ); } + #[tokio::test] + async fn identity_reports_the_configured_model_without_spawning() { + // "claude-unused" does not exist; the configured path must answer + // without ever spawning a process. + let backend = ClaudeAppBackend::with_turn_timeout( + "claude-unused", + vec![], + Some("claude-fable-5".into()), + None, + None, + None, + ); + + let identity = backend.identity().await; + + assert_eq!(identity.backend, "claude_app"); + assert_eq!(identity.model.as_deref(), Some("claude-fable-5")); + assert!(identity.models.is_empty()); + } + + #[tokio::test] + async fn identity_joins_differing_discovery_and_patch_models() { + let backend = ClaudeAppBackend::with_turn_timeout( + "claude-unused", + vec![], + Some("claude-opus-4-8".into()), + Some("claude-haiku-4-5".into()), + None, + None, + ); + + let identity = backend.identity().await; + + assert_eq!( + identity.model.as_deref(), + Some("claude-haiku-4-5,claude-opus-4-8") + ); + } + + #[tokio::test] + async fn identity_falls_back_to_the_last_reported_model() { + let backend = + ClaudeAppBackend::with_turn_timeout("claude-unused", vec![], None, None, None, None); + backend.state.lock().await.last_reported_model = Some("claude-fable-5".into()); + + let identity = backend.identity().await; + + assert_eq!(identity.model.as_deref(), Some("claude-fable-5")); + } + + #[tokio::test] + async fn read_init_model_parses_the_init_event_from_the_process_stream() { + // `echo` stands in for the CLI: it prints the init event and exits. + let init = json!({ + "type": "system", + "subtype": "init", + "session_id": "abc", + "model": "claude-fable-5" + }); + let mut child = Command::new("echo") + .arg(init.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut slot = ClaudeSlot { + process: Some(ClaudeAppProcess { + child, + stdin, + stdout: BufReader::new(stdout).lines(), + }), + ..ClaudeSlot::default() + }; + + assert_eq!( + read_init_model(&mut slot).await.as_deref(), + Some("claude-fable-5") + ); + } + #[test] fn turn_prompt_omits_unchanged_context() { let req = crate::test_request(); diff --git a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs index 444068f..d1a3799 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs @@ -19,8 +19,8 @@ use crate::support::{ optional_env, report_progress, turn_phase, turn_timeout_from_env, }; use crate::{ - BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, ProgressReporter, - enforce_card_contract, estimate_tokens, + BackendAdapter, BackendIdentity, BackendMetadata, BackendRequest, BackendResponse, + ProgressReporter, enforce_card_contract, estimate_tokens, }; use transport::{CodexAppProcess, CodexAppState, TurnOutput}; @@ -381,6 +381,17 @@ impl BackendAdapter for CodexAppBackend { self.warm_up().await } + async fn identity(&self) -> BackendIdentity { + BackendIdentity { + backend: "codex_app".into(), + // The app-server initialize handshake reports no default model or + // model list, so only the configured model can be named; turns + // with model: null use the server's own default. + model: self.model.clone(), + models: vec![], + } + } + fn capabilities(&self) -> BackendInfo { BackendInfo { name: "codex_app".into(), @@ -625,6 +636,18 @@ mod tests { ); } + #[tokio::test] + async fn identity_reports_the_configured_model_without_spawning() { + let backend = + CodexAppBackend::new("codex-unused", vec![], Some("gpt-5.3-codex".into()), None); + + let identity = backend.identity().await; + + assert_eq!(identity.backend, "codex_app"); + assert_eq!(identity.model.as_deref(), Some("gpt-5.3-codex")); + assert!(identity.models.is_empty()); + } + #[test] fn unchanged_context_is_not_repeated_in_thread_prompt() { let request = request(); diff --git a/rust/crates/loopbiotic_backends/src/lib.rs b/rust/crates/loopbiotic_backends/src/lib.rs index d67b5b1..1aad322 100644 --- a/rust/crates/loopbiotic_backends/src/lib.rs +++ b/rust/crates/loopbiotic_backends/src/lib.rs @@ -44,9 +44,27 @@ pub trait BackendAdapter: Send + Sync { Ok(()) } + /// Reports who will answer the next turn: the adapter name, the concrete + /// model it resolves to (configured, else discovered, else unknown), and + /// any models the backend can enumerate for switching. + async fn identity(&self) -> BackendIdentity { + BackendIdentity { + backend: self.capabilities().name, + model: None, + models: vec![], + } + } + fn capabilities(&self) -> BackendInfo; } +#[derive(Clone, Debug, Serialize)] +pub struct BackendIdentity { + pub backend: String, + pub model: Option, + pub models: Vec, +} + pub type ProgressReporter = Arc; #[derive(Clone, Debug, Serialize)] @@ -252,6 +270,35 @@ mod tests { use super::*; use loopbiotic_protocol::{HypothesisCard, SummaryCard}; + struct BareAdapter; + + #[async_trait] + impl BackendAdapter for BareAdapter { + async fn next_card(&self, _req: BackendRequest) -> Result { + unreachable!("identity tests never run a turn") + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "bare".into(), + streaming: false, + patches: false, + reasoning: false, + can_read_project: false, + can_use_tools: false, + } + } + } + + #[tokio::test] + async fn default_identity_reports_the_adapter_name_without_a_model() { + let identity = BareAdapter.identity().await; + + assert_eq!(identity.backend, "bare"); + assert_eq!(identity.model, None); + assert!(identity.models.is_empty()); + } + fn hypothesis() -> Card { Card::Hypothesis(HypothesisCard { id: "c_hypothesis".into(), diff --git a/rust/crates/loopbiotic_backends/src/mock.rs b/rust/crates/loopbiotic_backends/src/mock.rs index eafd48c..4f72e90 100644 --- a/rust/crates/loopbiotic_backends/src/mock.rs +++ b/rust/crates/loopbiotic_backends/src/mock.rs @@ -7,8 +7,8 @@ use loopbiotic_protocol::{ use serde_json::to_string; use crate::{ - BackendAction, BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, - estimate_tokens, + BackendAction, BackendAdapter, BackendIdentity, BackendMetadata, BackendRequest, + BackendResponse, estimate_tokens, }; #[derive(Default)] @@ -57,6 +57,16 @@ impl BackendAdapter for MockBackend { }) } + /// Fixed identity so the warmup identity contract is testable end to end + /// without a real model backend. + async fn identity(&self) -> BackendIdentity { + BackendIdentity { + backend: "mock".into(), + model: Some("mock-model".into()), + models: vec!["mock-model".into(), "mock-mini".into()], + } + } + fn capabilities(&self) -> BackendInfo { Self::info() } diff --git a/rust/crates/loopbiotic_backends/src/ollama.rs b/rust/crates/loopbiotic_backends/src/ollama.rs index 714a64d..1490415 100644 --- a/rust/crates/loopbiotic_backends/src/ollama.rs +++ b/rust/crates/loopbiotic_backends/src/ollama.rs @@ -1,15 +1,21 @@ +use std::time::Duration; + use anyhow::{Result, anyhow}; use async_trait::async_trait; use loopbiotic_protocol::{BackendInfo, Card, TokenUsage}; use serde::Deserialize; -use serde_json::json; +use serde_json::{Value, json}; use crate::support::{error_card, optional_env, report_progress, turn_timeout_from_env}; use crate::{ - BackendAdapter, BackendMetadata, BackendRequest, BackendResponse, ProgressReporter, - enforce_card_contract, estimate_tokens, + BackendAdapter, BackendIdentity, BackendMetadata, BackendRequest, BackendResponse, + ProgressReporter, enforce_card_contract, estimate_tokens, }; +/// Listing installed models is identity garnish, not a turn; a hung server +/// must not stall the warmup RPC for the full turn deadline. +const LIST_MODELS_TIMEOUT: Duration = Duration::from_secs(3); + /// Talks to a local Ollama server over its HTTP API instead of spawning /// `ollama run` per card. The server keeps the model loaded between turns /// (`keep_alive`), and `format: json` forces the model to emit parseable ops. @@ -91,11 +97,43 @@ impl OllamaBackend { Ok(response.json().await?) } + /// Names the models installed on the server via `GET /api/tags`. Any + /// failure yields an empty list: identity must never fail because the + /// listing did. + async fn list_models(&self) -> Vec { + let response = self + .client + .get(format!("{}/api/tags", self.host)) + .timeout(LIST_MODELS_TIMEOUT) + .send() + .await; + + match response { + Ok(response) if response.status().is_success() => response + .json::() + .await + .map(|tags| model_names(&tags)) + .unwrap_or_default(), + _ => vec![], + } + } + fn error_card(message: impl Into) -> Card { error_card("c_ollama_error", "Ollama error", message) } } +/// Extracts `models[].name` from an `/api/tags` response body. +fn model_names(tags: &Value) -> Vec { + tags.get("models") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|model| model.get("name").and_then(Value::as_str)) + .map(str::to_string) + .collect() +} + #[async_trait] impl BackendAdapter for OllamaBackend { async fn next_card(&self, req: BackendRequest) -> Result { @@ -140,6 +178,16 @@ impl BackendAdapter for OllamaBackend { }) } + async fn identity(&self) -> BackendIdentity { + BackendIdentity { + backend: "ollama".into(), + // The model env is required, so the next turn's model is always + // known without asking the server. + model: Some(self.model.clone()), + models: self.list_models().await, + } + } + fn capabilities(&self) -> BackendInfo { BackendInfo { name: "ollama".into(), @@ -151,3 +199,40 @@ impl BackendAdapter for OllamaBackend { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_names_extracts_names_from_a_tags_response() { + let tags = json!({ + "models": [ + {"name": "qwen3:8b", "size": 5}, + {"name": "llama3.2:3b"}, + {"size": 7} + ] + }); + + assert_eq!(model_names(&tags), vec!["qwen3:8b", "llama3.2:3b"]); + } + + #[test] + fn model_names_tolerates_malformed_responses() { + assert!(model_names(&json!({})).is_empty()); + assert!(model_names(&json!({"models": "nope"})).is_empty()); + assert!(model_names(&json!(null)).is_empty()); + } + + #[tokio::test] + async fn identity_reports_the_required_model_when_listing_fails() { + // Nothing listens on this port; the listing must fail quietly. + let backend = OllamaBackend::new("http://127.0.0.1:9", "qwen3:8b", "30m"); + + let identity = backend.identity().await; + + assert_eq!(identity.backend, "ollama"); + assert_eq!(identity.model.as_deref(), Some("qwen3:8b")); + assert!(identity.models.is_empty()); + } +} diff --git a/rust/crates/loopbiotic_protocol/src/lib.rs b/rust/crates/loopbiotic_protocol/src/lib.rs index 04dd131..2c89865 100644 --- a/rust/crates/loopbiotic_protocol/src/lib.rs +++ b/rust/crates/loopbiotic_protocol/src/lib.rs @@ -4,7 +4,7 @@ pub mod context; pub mod patch; pub mod rpc; -pub const PROTOCOL_VERSION: u32 = 8; +pub const PROTOCOL_VERSION: u32 = 9; pub use agent::*; pub use card::*; diff --git a/rust/crates/loopbioticd/src/main.rs b/rust/crates/loopbioticd/src/main.rs index 398c37b..7b70211 100644 --- a/rust/crates/loopbioticd/src/main.rs +++ b/rust/crates/loopbioticd/src/main.rs @@ -400,7 +400,7 @@ impl Server { .await .map_err(|error| (id.clone(), error.to_string()))?; - json!({"ok": true}) + json!({"ok": true, "identity": self.backend.identity().await}) } "shutdown" => json!({"ok": true}), method => return Err((id, format!("unknown method {method}"))), diff --git a/rust/crates/loopbioticd/tests/rpc.rs b/rust/crates/loopbioticd/tests/rpc.rs index fd90dde..2e239c4 100644 --- a/rust/crates/loopbioticd/tests/rpc.rs +++ b/rust/crates/loopbioticd/tests/rpc.rs @@ -247,6 +247,29 @@ fn session_start_returns_first_mock_card() { assert_eq!(follow["result"]["card"]["kind"], json!("finding")); } +#[test] +fn backend_warmup_reports_the_backend_identity() { + let mut daemon = Daemon::spawn(); + + let init = daemon.request("1", "initialize", json!({})); + assert!(init.get("error").is_none(), "unexpected error: {init}"); + + let response = daemon.request("2", "backend/warmup", json!({})); + + assert!( + response.get("error").is_none(), + "unexpected error: {response}" + ); + let result = &response["result"]; + assert_eq!(result["ok"], json!(true)); + assert_eq!(result["identity"]["backend"], json!("mock")); + assert_eq!(result["identity"]["model"], json!("mock-model")); + assert_eq!( + result["identity"]["models"], + json!(["mock-model", "mock-mini"]) + ); +} + #[test] fn unknown_method_returns_error_response() { let mut daemon = Daemon::spawn(); From 87eec5ab7c03df444c648bd273d6560a1a66a0f6 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 14:45:35 +0200 Subject: [PATCH 23/37] feat: show the resolved model and pick models from the prompt window --- README.md | 26 +++++++ doc/loopbiotic.txt | 8 ++ lua/loopbiotic/config.lua | 4 +- lua/loopbiotic/health.lua | 2 +- lua/loopbiotic/init.lua | 20 ++++- lua/loopbiotic/prompt.lua | 153 +++++++++++++++++++++++++++++++++++--- lua/loopbiotic/rpc.lua | 7 ++ lua/loopbiotic/state.lua | 2 + tests/lua/test_prompt.lua | 90 ++++++++++++++++++++++ 9 files changed, 296 insertions(+), 16 deletions(-) create mode 100644 tests/lua/test_prompt.lua diff --git a/README.md b/README.md index 0155a92..145eac5 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,32 @@ restores it on the next Neovim start. A model explicitly configured in `setup()` always takes precedence. `:LoopbioticModel default` clears the stored model and returns that agent to its own default. +The prompt window title always names the active agent and the concrete model +the next turn will use, e.g. `codex / gpt-5.4-mini`. Without a configured +model it shows the model the backend announces during warmup (or reported +after the last turn), and `model?` until one is known — it never shows +`default`. Press `` (`keymaps.models`) inside the prompt to pick a model +from every known candidate: the configured model, the models the backend +enumerates (for example Ollama's local tags), an optional `models` list on the +agent definition, and the model reported by the last turn. The picked model +persists per agent exactly like `:LoopbioticModel`; the prompt window and its +typed text stay open. + +```lua +require("loopbiotic").setup({ + agents = { + ["local"] = { + kind = "ollama", + host = "http://127.0.0.1:11434", + models = { "qwen2.5-coder:7b", "llama3.1:8b" }, -- extra picker candidates + }, + }, + keymaps = { + models = "", -- model picker inside the prompt window + }, +}) +``` + ## Flow ```text diff --git a/doc/loopbiotic.txt b/doc/loopbiotic.txt index 23e8d5e..306cddd 100644 --- a/doc/loopbiotic.txt +++ b/doc/loopbiotic.txt @@ -44,6 +44,14 @@ The session token total is shown against `backend.token_budget` (50000 by default). Loopbiotic asks for confirmation before another agent turn after that budget is reached. Set it to 0 to disable the guard. +The prompt window title names the active agent and the concrete model the +next turn will use, falling back to the backend-announced model and then +`model?` — it never shows `default`. Press `keymaps.models` (`` by +default) inside the prompt to pick from the known models: the configured +model, the models the backend enumerates, the agent's `models` list in +`setup()`, and the model reported by the last turn. The choice persists per +agent like |:LoopbioticModel| and keeps the prompt window and its text open. + Loopbiotic has been tested primarily with the Codex CLI app-server backend. =============================================================================== diff --git a/lua/loopbiotic/config.lua b/lua/loopbiotic/config.lua index e453fcd..6972728 100644 --- a/lua/loopbiotic/config.lua +++ b/lua/loopbiotic/config.lua @@ -14,7 +14,7 @@ local M = {} ---@field args? string[] ---@field model? string ---@field model_flag? string ----@field models? string[] +---@field models? string[] extra model-picker candidates for this agent ---@field effort? string codex_app only ---@field discovery_model? string claude_app only ---@field discovery_thinking? integer claude_app only @@ -113,6 +113,8 @@ M.values = { draft_accept = "pa", draft_reject = "pd", draft_retry = "pt", + -- Model picker inside the prompt window (buffer-local, insert and normal). + models = "", }, prompt = { border = "rounded", diff --git a/lua/loopbiotic/health.lua b/lua/loopbiotic/health.lua index 29e989c..dd6bde4 100644 --- a/lua/loopbiotic/health.lua +++ b/lua/loopbiotic/health.lua @@ -62,7 +62,7 @@ function M.check() local agent_name, agent = config.agent_config() info("Active agent: " .. agent_name) - info("Active model: " .. (agent.model or "default")) + info("Active model: " .. require("loopbiotic").model_display()) if agent.command then if vim.fn.executable(agent.command) == 1 then ok("Agent command: " .. agent.command) diff --git a/lua/loopbiotic/init.lua b/lua/loopbiotic/init.lua index d1f7b28..4ab6455 100644 --- a/lua/loopbiotic/init.lua +++ b/lua/loopbiotic/init.lua @@ -519,22 +519,36 @@ function M.agents() return config.agent_names() end +-- Concrete model to display for the active agent: configured model, then +-- the backend/warmup identity, then the model reported after the last turn. +-- The word "default" is never displayed. +function M.model_display() + local identity = state.agent_identity + local identity_model = type(identity) == "table" and identity.model or nil + local resolved = prompt.resolved_model(config.model(), identity_model, state.backend_model) + + return resolved or "agent default (not yet resolved)" +end + function M.model(name) if not name or name == "" then local model = config.model() - ui.notify("Loopbiotic model: " .. (model or "default")) + ui.notify("Loopbiotic model: " .. M.model_display()) return model end + -- "default" and "none" stay accepted as inputs: they clear the stored + -- per-agent preference. Only the displayed name changes. if name == "default" or name == "none" then local _, saved, save_error = config.model("") rpc.stop() + local display = M.model_display() if save_error then - ui.notify("Loopbiotic model: default (could not save: " .. save_error .. ")", vim.log.levels.WARN) + ui.notify("Loopbiotic model: " .. display .. " (could not save: " .. save_error .. ")", vim.log.levels.WARN) else - ui.notify("Loopbiotic model: default" .. (saved and " · saved" or "")) + ui.notify("Loopbiotic model: " .. display .. (saved and " · saved" or "")) end return nil diff --git a/lua/loopbiotic/prompt.lua b/lua/loopbiotic/prompt.lua index 651bce9..01c1303 100644 --- a/lua/loopbiotic/prompt.lua +++ b/lua/loopbiotic/prompt.lua @@ -4,16 +4,22 @@ local ui = require("loopbiotic.ui") local M = {} +-- Which window kind ("Prompt"/"Reply") is currently open, so the async +-- warmup response can re-render the matching frame title. +local open_kind = "Prompt" + function M.open(mode) local source = require("loopbiotic.context").capture() -- Let the backend pay its startup cost (CLI boot, process spawn) while the - -- user is still typing the prompt. - require("loopbiotic.rpc").request("backend/warmup", {}, function() end) + -- user is still typing the prompt. The response also carries the backend + -- identity (concrete model, known models) used for the title and picker. + require("loopbiotic.rpc").request("backend/warmup", {}, M.on_warmup) + open_kind = "Prompt" M.open_for({ title = M.title("Prompt"), - footer = " /kind forces card type Ctrl-s submit Esc normal q close ", + footer = " Ctrl-l model /kind forces card type Ctrl-s submit Esc normal q close ", submit = function(text) require("loopbiotic").start(text, mode, source) end, @@ -21,26 +27,144 @@ function M.open(mode) end function M.reply() + open_kind = "Reply" M.open_for({ title = M.title("Reply"), - footer = " Ctrl-s send Esc normal q close ", + footer = " Ctrl-l model Ctrl-s send Esc normal q close ", submit = function(text) require("loopbiotic").reply(text) end, }) end +-- Store the identity reported by backend/warmup and refresh the open prompt +-- title with it. Old daemons answer {ok = true} without an identity field; +-- tolerate that (and error responses) by keeping the previous state. +---@param message table RPC response ({ result = ... } or { error = ... }) +function M.on_warmup(message) + if message.error or type(message.result) ~= "table" then + return + end + + local identity = message.result.identity + if type(identity) ~= "table" then + return + end + + state.agent_identity = identity + M.refresh_title() +end + +-- Re-render the frame title of the currently open prompt window, if any. +-- Callers may run outside the main loop (RPC callbacks), hence the schedule. +function M.refresh_title() + vim.schedule(function() + local frame_win = state.prompt_frame_win + if not (frame_win and vim.api.nvim_win_is_valid(frame_win)) then + return + end + + pcall(vim.api.nvim_win_set_config, frame_win, { title = M.title(open_kind), title_pos = "left" }) + end) +end + +-- Pick the concrete model out of the fixed resolution order: configured +-- model, then the model the warmup identity announced for the next turn, +-- then the model the backend reported after a turn. Returns nil when none +-- is known. vim.NIL (JSON null) and empty strings count as unknown. +---@param configured string|nil +---@param identity_model string|nil +---@param backend_model string|nil +---@return string|nil +function M.resolved_model(configured, identity_model, backend_model) + local candidates = { configured, identity_model, backend_model } + + for index = 1, 3 do + local value = candidates[index] + if type(value) == "string" and value ~= "" then + return value + end + end + + return nil +end + +-- Title-ready model name; "model?" until any concrete model is known. The +-- word "default" is never rendered. +---@param configured string|nil +---@param identity_model string|nil +---@param backend_model string|nil +---@return string +function M.model_label(configured, identity_model, backend_model) + return M.resolved_model(configured, identity_model, backend_model) or "model?" +end + +-- Deduped model-picker candidates, in resolution-priority order: configured +-- model, identity model, backend-enumerated models, the agent's `models` +-- config list, the model reported after the last turn. +---@param configured string|nil +---@param identity table|nil backend/warmup identity ({ model, models }) +---@param agent_models string[]|nil the agent's `models` config list +---@param backend_model string|nil +---@return string[] +function M.model_candidates(configured, identity, agent_models, backend_model) + local seen = {} + local candidates = {} + local function add(value) + if type(value) == "string" and value ~= "" and not seen[value] then + seen[value] = true + table.insert(candidates, value) + end + end + + add(configured) + if type(identity) == "table" then + add(identity.model) + if type(identity.models) == "table" then + for _, name in ipairs(identity.models) do + add(name) + end + end + end + for _, name in ipairs(agent_models or {}) do + add(name) + end + add(backend_model) + + return candidates +end + function M.title(kind) local agent = config.agent() - local model = config.model() - if not model or model == "" then - -- No model configured: show what the backend reported it is actually - -- using, once a turn has completed. - model = state.backend_model + local identity = state.agent_identity + local identity_model = type(identity) == "table" and identity.model or nil + local model = M.model_label(config.model(), identity_model, state.backend_model) + + return string.format(" Loopbiotic %s · %s / %s ", kind, agent, model) +end + +-- Open a picker over every model known for the active agent. The choice +-- goes through the regular model-switch entry point (persisting the +-- per-agent preference); only the frame title changes, the typed prompt +-- text and window stay as they are. +function M.pick_model() + local agent = config.agent() + local identity = state.agent_identity + local candidates = M.model_candidates(config.model(), identity, config.model_names(), state.backend_model) + + if #candidates == 0 then + ui.notify("No known models for " .. agent .. " — use :LoopbioticModel ", vim.log.levels.WARN) + return end - local active = model and model ~= "" and (agent .. " / " .. model) or (agent .. " / default") - return string.format(" Loopbiotic %s · %s ", kind, active) + vim.ui.select(candidates, { prompt = "Loopbiotic model (" .. agent .. ")" }, function(choice) + if not choice or choice == "" then + return + end + + require("loopbiotic").model(choice) + M.refresh_title() + end) end function M.open_for(opts) @@ -112,6 +236,13 @@ function M.bind(buf, submit) M.submit(buf, submit) end, { buffer = buf, nowait = true, silent = true }) + local models_key = config.values.keymaps.models + if models_key and models_key ~= "" then + vim.keymap.set({ "i", "n" }, models_key, function() + M.pick_model() + end, { buffer = buf, nowait = true, silent = true }) + end + vim.keymap.set("n", "", function() M.submit(buf, submit) end, { buffer = buf, nowait = true, silent = true }) diff --git a/lua/loopbiotic/rpc.lua b/lua/loopbiotic/rpc.lua index d79975f..319c73e 100644 --- a/lua/loopbiotic/rpc.lua +++ b/lua/loopbiotic/rpc.lua @@ -1,6 +1,7 @@ local config = require("loopbiotic.config") local installer = require("loopbiotic.installer") local log = require("loopbiotic.log") +local state = require("loopbiotic.state") local ui = require("loopbiotic.ui") local M = { @@ -96,6 +97,9 @@ function M.start(backend_command) end M.job = nil M.ready = false + -- The warmup identity described this process; the next one may report + -- a different backend or model. + state.agent_identity = nil M.fail_all("Loopbiotic backend exited with code " .. code) end, }) @@ -159,6 +163,9 @@ function M.stop() M.incompatible = false M.starting = false M.buffer = "" + -- Agent and model switches restart the backend through this path, so the + -- previously reported identity no longer applies. + state.agent_identity = nil M.fail_all("Loopbiotic backend was stopped") end diff --git a/lua/loopbiotic/state.lua b/lua/loopbiotic/state.lua index b9fbe56..9b72f7c 100644 --- a/lua/loopbiotic/state.lua +++ b/lua/loopbiotic/state.lua @@ -46,6 +46,7 @@ ---@field token_usage LoopbioticTokenUsage|nil ---@field turn_token_usage LoopbioticTokenUsage|nil ---@field backend_model string|nil model the backend reported using +---@field agent_identity table|nil backend/warmup identity: { backend, model, models } ---@field context_report table|nil ---@field workspace_hints table[]|nil ---@field completion_notified_card string|nil @@ -91,6 +92,7 @@ local defaults = { token_usage = vim.NIL, turn_token_usage = vim.NIL, backend_model = vim.NIL, + agent_identity = vim.NIL, context_report = vim.NIL, workspace_hints = vim.NIL, completion_notified_card = vim.NIL, diff --git a/tests/lua/test_prompt.lua b/tests/lua/test_prompt.lua new file mode 100644 index 0000000..5c2a4a5 --- /dev/null +++ b/tests/lua/test_prompt.lua @@ -0,0 +1,90 @@ +return function(t) + local config = require("loopbiotic.config") + local prompt = require("loopbiotic.prompt") + local state = require("loopbiotic.state") + + t.test("model_label prefers the configured model", function() + t.eq(prompt.model_label("configured", "identity", "backend"), "configured") + end) + + t.test("model_label falls back to identity, then backend, then model?", function() + t.eq(prompt.model_label(nil, "identity", "backend"), "identity") + t.eq(prompt.model_label(nil, nil, "backend"), "backend") + t.eq(prompt.model_label(nil, nil, nil), "model?") + end) + + t.test("model_label treats empty strings and vim.NIL as unknown", function() + t.eq(prompt.model_label("", "", ""), "model?") + t.eq(prompt.model_label(vim.NIL, vim.NIL, vim.NIL), "model?") + t.eq(prompt.model_label("", vim.NIL, "backend"), "backend") + end) + + t.test("model_label never yields the word default", function() + t.eq(prompt.model_label(nil, nil, nil) ~= "default", true) + t.eq(prompt.model_label("default", nil, nil), "default") -- explicit input passes through + end) + + t.test("title renders agent and resolved model, never default", function() + local previous_agent = config.values.backend.agent + config.values.backend.agent = "mock" + state.agent_identity = nil + state.backend_model = nil + + t.eq(prompt.title("Prompt"), " Loopbiotic Prompt · mock / model? ") + + state.agent_identity = { backend = "mock", model = "claude-fable-5", models = {} } + t.eq(prompt.title("Reply"), " Loopbiotic Reply · mock / claude-fable-5 ") + t.eq(prompt.title("Reply"):find("default", 1, true), nil, "no default in title") + + state.agent_identity = nil + config.values.backend.agent = previous_agent + end) + + t.test("model_candidates unions and dedupes in priority order", function() + local candidates = prompt.model_candidates( + "configured", + { model = "identity", models = { "alpha", "configured", "beta" } }, + { "beta", "gamma" }, + "backend" + ) + + t.eq(candidates, { "configured", "identity", "alpha", "beta", "gamma", "backend" }) + end) + + t.test("model_candidates filters nil, vim.NIL, and empty values", function() + t.eq(prompt.model_candidates(nil, nil, nil, nil), {}) + t.eq(prompt.model_candidates("", { model = vim.NIL, models = vim.NIL }, {}, ""), {}) + t.eq(prompt.model_candidates(nil, { models = { "", "only" } }, nil, nil), { "only" }) + end) + + t.test("on_warmup stores the identity and tolerates old daemons", function() + state.agent_identity = nil + + prompt.on_warmup({ error = { code = -32098, message = "stopped" } }) + t.eq(state.agent_identity, nil, "error response") + + prompt.on_warmup({ result = { ok = true } }) + t.eq(state.agent_identity, nil, "legacy daemon without identity") + + prompt.on_warmup({ result = { ok = true, identity = vim.NIL } }) + t.eq(state.agent_identity, nil, "null identity") + + local identity = { backend = "mock", model = "mock-model", models = { "mock-model", "mock-mini" } } + prompt.on_warmup({ result = { ok = true, identity = identity } }) + t.eq(state.agent_identity, identity, "identity stored") + + state.agent_identity = nil + end) + + t.test("rpc.stop clears the stored agent identity", function() + state.agent_identity = { backend = "mock", model = "mock-model" } + + require("loopbiotic.rpc").stop() + + t.eq(state.agent_identity, nil) + end) + + t.test("keymaps.models defaults to ", function() + t.eq(config.values.keymaps.models, "") + end) +end From f3cf1e0d8f68d332099a33a59464859a965b1078 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 14:45:35 +0200 Subject: [PATCH 24/37] docs: changelog for identity handshake and model picker --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe304f..5a460cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,16 @@ The project follows [Semantic Versioning](https://semver.org/). ### Added +- The `backend/warmup` handshake now reports an explicit identity: the active + backend, the concrete model the next turn will use (configured, or resolved + from the backend — the Claude CLI announces it at process start, Ollama + always knows it), and the models the backend can enumerate (Ollama's local + tags). Protocol version is now 9. +- The prompt window title names the active agent and resolved model (never + "default"), refreshing as soon as warmup resolves it, and `Ctrl-l` + (`keymaps.models`) opens a model picker fed by the backend-enumerated + models, an optional per-agent `models` list, and the last reported model. + Selections persist per agent exactly like `:LoopbioticModel`. - Lua tooling (`stylua`, `selene`, LuaLS config) enforced in CI, headless Lua unit tests for the patch engine and session state, `loopbioticd` JSON-RPC integration tests, session state-machine transition tests, and a real From 3631381a1fb3d672eeac4c9690a579de94b13f1c Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 15:25:17 +0200 Subject: [PATCH 25/37] fix: report phase-aware claude identity and offer CLI alias models --- CHANGELOG.md | 5 + README.md | 13 +- doc/loopbiotic.txt | 12 +- lua/loopbiotic/init.lua | 10 +- lua/loopbiotic/prompt.lua | 27 +++- .../loopbiotic_backends/src/claude_app.rs | 145 +++++++++++++----- .../loopbiotic_backends/src/codex_app/mod.rs | 1 + rust/crates/loopbiotic_backends/src/lib.rs | 11 ++ rust/crates/loopbiotic_backends/src/mock.rs | 1 + rust/crates/loopbiotic_backends/src/ollama.rs | 1 + tests/lua/test_prompt.lua | 34 +++- 11 files changed, 197 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a460cc..f8d1b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,11 @@ The project follows [Semantic Versioning](https://semver.org/). (`keymaps.models`) opens a model picker fed by the backend-enumerated models, an optional per-agent `models` list, and the last reported model. Selections persist per agent exactly like `:LoopbioticModel`. +- Identity is phase-aware: the reported model is always the patch-drafting + one, and a differing pinned discovery model (the shipped claude agent uses + `discovery_model = "haiku"`) is shown separately instead of masquerading + as the active model. The claude picker offers the CLI aliases `sonnet`, + `opus`, and `haiku` since the CLI has no model-listing API. - Lua tooling (`stylua`, `selene`, LuaLS config) enforced in CI, headless Lua unit tests for the patch engine and session state, `loopbioticd` JSON-RPC integration tests, session state-machine transition tests, and a real diff --git a/README.md b/README.md index 145eac5..a278c7f 100644 --- a/README.md +++ b/README.md @@ -163,10 +163,15 @@ The prompt window title always names the active agent and the concrete model the next turn will use, e.g. `codex / gpt-5.4-mini`. Without a configured model it shows the model the backend announces during warmup (or reported after the last turn), and `model?` until one is known — it never shows -`default`. Press `` (`keymaps.models`) inside the prompt to pick a model -from every known candidate: the configured model, the models the backend -enumerates (for example Ollama's local tags), an optional `models` list on the -agent definition, and the model reported by the last turn. The picked model +`default`. The title always names the patch-drafting model; when an agent +runs discovery on a different model (the shipped claude agent pins +`discovery_model = "haiku"`), that is shown separately, e.g. +`claude-fable-5 · discovery haiku`. Press `` (`keymaps.models`) inside +the prompt to pick a model from every known candidate: the configured model, +the models the backend enumerates (Ollama's local tags; claude offers its +stable CLI aliases `sonnet`, `opus`, `haiku`), an optional `models` list on +the agent definition, and the model reported by the last turn. Picking sets +the patch model; `discovery_model` stays as configured. The picked model persists per agent exactly like `:LoopbioticModel`; the prompt window and its typed text stay open. diff --git a/doc/loopbiotic.txt b/doc/loopbiotic.txt index 306cddd..0a00e6b 100644 --- a/doc/loopbiotic.txt +++ b/doc/loopbiotic.txt @@ -46,11 +46,15 @@ budget is reached. Set it to 0 to disable the guard. The prompt window title names the active agent and the concrete model the next turn will use, falling back to the backend-announced model and then -`model?` — it never shows `default`. Press `keymaps.models` (`` by +`model?` — it never shows `default`. The title always names the +patch-drafting model; a differing pinned discovery model (the shipped claude +agent uses `discovery_model = "haiku"`) is shown separately, e.g. +`claude-fable-5 · discovery haiku`. Press `keymaps.models` (`` by default) inside the prompt to pick from the known models: the configured -model, the models the backend enumerates, the agent's `models` list in -`setup()`, and the model reported by the last turn. The choice persists per -agent like |:LoopbioticModel| and keeps the prompt window and its text open. +model, the models the backend enumerates (claude offers the CLI aliases +`sonnet`, `opus`, `haiku`), the agent's `models` list in `setup()`, and the +model reported by the last turn. Picking sets the patch model and persists +per agent like |:LoopbioticModel|, keeping the prompt window and text open. Loopbiotic has been tested primarily with the Codex CLI app-server backend. diff --git a/lua/loopbiotic/init.lua b/lua/loopbiotic/init.lua index 4ab6455..ee62064 100644 --- a/lua/loopbiotic/init.lua +++ b/lua/loopbiotic/init.lua @@ -523,11 +523,13 @@ end -- the backend/warmup identity, then the model reported after the last turn. -- The word "default" is never displayed. function M.model_display() - local identity = state.agent_identity - local identity_model = type(identity) == "table" and identity.model or nil - local resolved = prompt.resolved_model(config.model(), identity_model, state.backend_model) + local label = prompt.model_label(config.model(), state.agent_identity, state.backend_model) - return resolved or "agent default (not yet resolved)" + if label:find("model?", 1, true) == 1 then + return (label:gsub("^model%?", "agent default (not yet resolved)")) + end + + return label end function M.model(name) diff --git a/lua/loopbiotic/prompt.lua b/lua/loopbiotic/prompt.lua index 01c1303..e1015ac 100644 --- a/lua/loopbiotic/prompt.lua +++ b/lua/loopbiotic/prompt.lua @@ -90,13 +90,24 @@ function M.resolved_model(configured, identity_model, backend_model) end -- Title-ready model name; "model?" until any concrete model is known. The --- word "default" is never rendered. +-- word "default" is never rendered. When the backend runs a different +-- discovery model (identity.phases), it is shown alongside the patch model +-- instead of being presented as "the" model. ---@param configured string|nil ----@param identity_model string|nil +---@param identity table|nil backend/warmup identity ({ model, models, phases }) ---@param backend_model string|nil ---@return string -function M.model_label(configured, identity_model, backend_model) - return M.resolved_model(configured, identity_model, backend_model) or "model?" +function M.model_label(configured, identity, backend_model) + local identity_model = type(identity) == "table" and identity.model or nil + local label = M.resolved_model(configured, identity_model, backend_model) or "model?" + local phases = type(identity) == "table" and type(identity.phases) == "table" and identity.phases or nil + local discovery = phases and phases.discovery + + if type(discovery) == "string" and discovery ~= "" and discovery ~= label then + return label .. " · discovery " .. discovery + end + + return label end -- Deduped model-picker candidates, in resolution-priority order: configured @@ -120,6 +131,10 @@ function M.model_candidates(configured, identity, agent_models, backend_model) add(configured) if type(identity) == "table" then add(identity.model) + if type(identity.phases) == "table" then + add(identity.phases.patch) + add(identity.phases.discovery) + end if type(identity.models) == "table" then for _, name in ipairs(identity.models) do add(name) @@ -136,9 +151,7 @@ end function M.title(kind) local agent = config.agent() - local identity = state.agent_identity - local identity_model = type(identity) == "table" and identity.model or nil - local model = M.model_label(config.model(), identity_model, state.backend_model) + local model = M.model_label(config.model(), state.agent_identity, state.backend_model) return string.format(" Loopbiotic %s · %s / %s ", kind, agent, model) end diff --git a/rust/crates/loopbiotic_backends/src/claude_app.rs b/rust/crates/loopbiotic_backends/src/claude_app.rs index 55d558f..bce6c04 100644 --- a/rust/crates/loopbiotic_backends/src/claude_app.rs +++ b/rust/crates/loopbiotic_backends/src/claude_app.rs @@ -16,8 +16,8 @@ use crate::support::{ optional_env, report_progress, turn_phase, turn_timeout_from_env, }; use crate::{ - BackendAdapter, BackendIdentity, BackendMetadata, BackendRequest, BackendResponse, - ProgressReporter, enforce_card_contract, estimate_tokens, + BackendAdapter, BackendIdentity, BackendMetadata, BackendPhaseModels, BackendRequest, + BackendResponse, ProgressReporter, enforce_card_contract, estimate_tokens, }; const SYSTEM_PROMPT: &str = r#"You are a local Loopbiotic pair-programming partner inside the user's editor. @@ -66,9 +66,10 @@ struct ClaudeAppState { // Pre-spawned discovery process created by warmup() before a session // exists, adopted by the next session's first discovery turn. warm: Option>>, - // Latest model the CLI reported (init event or turn result); survives - // slot turnover so identity() can answer without respawning. - last_reported_model: Option, + // Model a flagless CLI process reported — its true default. Only ever + // written from processes that ran without --model, so a pinned + // discovery model can never masquerade as the CLI default. + cli_default_model: Option, } #[derive(Default)] @@ -281,17 +282,6 @@ impl ClaudeAppBackend { } } - /// The configured model as one identity string: the discovery model, or - /// "discovery,patch" when the two phases run different models. - fn configured_model(&self) -> Option { - let discovery = self.phase_model(Phase::Discovery)?; - - match self.phase_model(Phase::Patch) { - Some(patch) if patch != discovery => Some(format!("{discovery},{patch}")), - _ => Some(discovery), - } - } - fn phase_thinking(&self, phase: Phase) -> Option { match phase { // Patch turns keep the CLI's adaptive thinking: diff correctness @@ -388,14 +378,28 @@ impl ClaudeAppBackend { Ok(()) } - /// Resolves the CLI's own default model when none is configured: the - /// latest model a turn or init event reported, else the init event of the - /// warm discovery process (spawning it warmup-style when needed). - async fn discovered_default_model(&self) -> Option { - if let Some(model) = self.state.lock().await.last_reported_model.clone() { + /// Resolves the CLI's own default model (what a process spawned without + /// --model runs): cached, else read from the warm discovery process when + /// discovery itself is flagless, else from a short-lived flagless probe. + async fn cli_default_model(&self) -> Option { + if let Some(model) = self.state.lock().await.cli_default_model.clone() { return Some(model); } + let model = if self.phase_model(Phase::Discovery).is_none() { + self.warm_init_model().await + } else { + self.probe_default_model().await + }; + + if let Some(model) = model.as_ref() { + self.state.lock().await.cli_default_model = Some(model.clone()); + } + + model + } + + async fn warm_init_model(&self) -> Option { self.warm_up().await.ok()?; let warm = self.state.lock().await.warm.clone()?; let mut slot = warm.lock().await; @@ -404,12 +408,20 @@ impl ClaudeAppBackend { // still unread on stdout. slot.reported_model = read_init_model(&mut slot).await; } - let model = slot.reported_model.clone(); - drop(slot); - if let Some(model) = model.as_ref() { - self.state.lock().await.last_reported_model = Some(model.clone()); - } + slot.reported_model.clone() + } + + /// The warm discovery process runs a pinned model, so it cannot reveal + /// the CLI default; spawn a flagless process just long enough to read + /// its init event. + async fn probe_default_model(&self) -> Option { + let mut slot = ClaudeSlot { + process: self.spawn_process(&None, &None).ok(), + ..ClaudeSlot::default() + }; + let model = read_init_model(&mut slot).await; + slot.kill_process(); model } @@ -662,7 +674,9 @@ impl BackendAdapter for ClaudeAppBackend { ) -> Result { let output = self.ask(&req, progress.as_ref()).await?; if let Some(model) = output.model.as_ref() { - self.state.lock().await.last_reported_model = Some(model.clone()); + if self.phase_model(turn_phase(&req)).is_none() { + self.state.lock().await.cli_default_model = Some(model.clone()); + } } let card = crate::parse_card(&output.text).unwrap_or_else(|error| { Self::error_card(format!("{error}\n\nRaw output:\n{}", output.text)) @@ -693,16 +707,38 @@ impl BackendAdapter for ClaudeAppBackend { } async fn identity(&self) -> BackendIdentity { - let model = match self.configured_model() { + // `model` names the patch-phase model — the one that writes code. + // A pinned discovery model is reported separately via `phases` so a + // cheap discovery default is never presented as "the" model. + let patch = match self.model.clone() { Some(model) => Some(model), - None => self.discovered_default_model().await, + None => self.cli_default_model().await, }; + let discovery = self.discovery_model.clone().or_else(|| patch.clone()); + let phases = (discovery != patch).then(|| BackendPhaseModels { + discovery: discovery.clone(), + patch: patch.clone(), + }); + + // The CLI has no model-listing API; offer the concrete models we + // know about plus the stable aliases the CLI resolves server-side. + let mut models: Vec = vec![]; + for candidate in [&patch, &discovery].into_iter().flatten() { + if !models.contains(candidate) { + models.push(candidate.clone()); + } + } + for alias in ["sonnet", "opus", "haiku"] { + if !models.iter().any(|known| known == alias) { + models.push(alias.into()); + } + } BackendIdentity { backend: "claude_app".into(), - model, - // The claude CLI has no model-listing API. - models: vec![], + model: patch, + models, + phases, } } @@ -1043,11 +1079,15 @@ mod tests { assert_eq!(identity.backend, "claude_app"); assert_eq!(identity.model.as_deref(), Some("claude-fable-5")); - assert!(identity.models.is_empty()); + assert!(identity.phases.is_none()); + assert_eq!( + identity.models, + ["claude-fable-5", "sonnet", "opus", "haiku"] + ); } #[tokio::test] - async fn identity_joins_differing_discovery_and_patch_models() { + async fn identity_reports_phase_models_when_discovery_differs() { let backend = ClaudeAppBackend::with_turn_timeout( "claude-unused", vec![], @@ -1059,21 +1099,48 @@ mod tests { let identity = backend.identity().await; - assert_eq!( - identity.model.as_deref(), - Some("claude-haiku-4-5,claude-opus-4-8") + // The patch model is "the" model; the pinned discovery model rides + // along in phases instead of hijacking the headline. + assert_eq!(identity.model.as_deref(), Some("claude-opus-4-8")); + let phases = identity.phases.expect("differing phases must be reported"); + assert_eq!(phases.discovery.as_deref(), Some("claude-haiku-4-5")); + assert_eq!(phases.patch.as_deref(), Some("claude-opus-4-8")); + } + + #[tokio::test] + async fn identity_never_reports_a_pinned_discovery_model_as_the_model() { + // The shipped default config: discovery pinned to a cheap model, no + // patch model configured. The probe command does not exist, so the + // CLI default stays unknown — identity must say so rather than + // claim the discovery model is what patch turns will run. + let backend = ClaudeAppBackend::with_turn_timeout( + "claude-unused", + vec![], + None, + Some("haiku".into()), + None, + None, ); + + let identity = backend.identity().await; + + assert_eq!(identity.model, None); + let phases = identity.phases.expect("differing phases must be reported"); + assert_eq!(phases.discovery.as_deref(), Some("haiku")); + assert_eq!(phases.patch, None); + assert_eq!(identity.models, ["haiku", "sonnet", "opus"]); } #[tokio::test] - async fn identity_falls_back_to_the_last_reported_model() { + async fn identity_falls_back_to_the_cached_cli_default() { let backend = ClaudeAppBackend::with_turn_timeout("claude-unused", vec![], None, None, None, None); - backend.state.lock().await.last_reported_model = Some("claude-fable-5".into()); + backend.state.lock().await.cli_default_model = Some("claude-fable-5".into()); let identity = backend.identity().await; assert_eq!(identity.model.as_deref(), Some("claude-fable-5")); + assert!(identity.phases.is_none()); } #[tokio::test] diff --git a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs index d1a3799..380568c 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs @@ -389,6 +389,7 @@ impl BackendAdapter for CodexAppBackend { // with model: null use the server's own default. model: self.model.clone(), models: vec![], + phases: None, } } diff --git a/rust/crates/loopbiotic_backends/src/lib.rs b/rust/crates/loopbiotic_backends/src/lib.rs index 1aad322..3afb8af 100644 --- a/rust/crates/loopbiotic_backends/src/lib.rs +++ b/rust/crates/loopbiotic_backends/src/lib.rs @@ -52,6 +52,7 @@ pub trait BackendAdapter: Send + Sync { backend: self.capabilities().name, model: None, models: vec![], + phases: None, } } @@ -63,6 +64,16 @@ pub struct BackendIdentity { pub backend: String, pub model: Option, pub models: Vec, + /// Set when the backend runs different models per turn phase; `model` + /// then names the patch-phase model (the one that writes code). + #[serde(skip_serializing_if = "Option::is_none")] + pub phases: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct BackendPhaseModels { + pub discovery: Option, + pub patch: Option, } pub type ProgressReporter = Arc; diff --git a/rust/crates/loopbiotic_backends/src/mock.rs b/rust/crates/loopbiotic_backends/src/mock.rs index 4f72e90..4561132 100644 --- a/rust/crates/loopbiotic_backends/src/mock.rs +++ b/rust/crates/loopbiotic_backends/src/mock.rs @@ -64,6 +64,7 @@ impl BackendAdapter for MockBackend { backend: "mock".into(), model: Some("mock-model".into()), models: vec!["mock-model".into(), "mock-mini".into()], + phases: None, } } diff --git a/rust/crates/loopbiotic_backends/src/ollama.rs b/rust/crates/loopbiotic_backends/src/ollama.rs index 1490415..100592a 100644 --- a/rust/crates/loopbiotic_backends/src/ollama.rs +++ b/rust/crates/loopbiotic_backends/src/ollama.rs @@ -185,6 +185,7 @@ impl BackendAdapter for OllamaBackend { // known without asking the server. model: Some(self.model.clone()), models: self.list_models().await, + phases: None, } } diff --git a/tests/lua/test_prompt.lua b/tests/lua/test_prompt.lua index 5c2a4a5..01e034c 100644 --- a/tests/lua/test_prompt.lua +++ b/tests/lua/test_prompt.lua @@ -4,19 +4,32 @@ return function(t) local state = require("loopbiotic.state") t.test("model_label prefers the configured model", function() - t.eq(prompt.model_label("configured", "identity", "backend"), "configured") + t.eq(prompt.model_label("configured", { model = "identity" }, "backend"), "configured") end) t.test("model_label falls back to identity, then backend, then model?", function() - t.eq(prompt.model_label(nil, "identity", "backend"), "identity") + t.eq(prompt.model_label(nil, { model = "identity" }, "backend"), "identity") t.eq(prompt.model_label(nil, nil, "backend"), "backend") t.eq(prompt.model_label(nil, nil, nil), "model?") end) t.test("model_label treats empty strings and vim.NIL as unknown", function() - t.eq(prompt.model_label("", "", ""), "model?") - t.eq(prompt.model_label(vim.NIL, vim.NIL, vim.NIL), "model?") - t.eq(prompt.model_label("", vim.NIL, "backend"), "backend") + t.eq(prompt.model_label("", { model = "" }, ""), "model?") + t.eq(prompt.model_label(vim.NIL, { model = vim.NIL }, vim.NIL), "model?") + t.eq(prompt.model_label("", { model = vim.NIL }, "backend"), "backend") + end) + + t.test("model_label shows a differing discovery model alongside, never as the model", function() + -- The shipped claude default: discovery pinned, patch model unknown. + local identity = { model = vim.NIL, phases = { discovery = "haiku", patch = vim.NIL } } + t.eq(prompt.model_label(nil, identity, nil), "model? · discovery haiku") + + identity = { model = "claude-fable-5", phases = { discovery = "haiku", patch = "claude-fable-5" } } + t.eq(prompt.model_label(nil, identity, nil), "claude-fable-5 · discovery haiku") + + -- Same model in both phases: no suffix. + identity = { model = "claude-fable-5" } + t.eq(prompt.model_label(nil, identity, nil), "claude-fable-5") end) t.test("model_label never yields the word default", function() @@ -51,6 +64,17 @@ return function(t) t.eq(candidates, { "configured", "identity", "alpha", "beta", "gamma", "backend" }) end) + t.test("model_candidates includes phase models", function() + local candidates = prompt.model_candidates( + nil, + { model = vim.NIL, phases = { discovery = "haiku", patch = vim.NIL }, models = { "sonnet", "haiku" } }, + nil, + nil + ) + + t.eq(candidates, { "haiku", "sonnet" }) + end) + t.test("model_candidates filters nil, vim.NIL, and empty values", function() t.eq(prompt.model_candidates(nil, nil, nil, nil), {}) t.eq(prompt.model_candidates("", { model = vim.NIL, models = vim.NIL }, {}, ""), {}) From 35a9e95556ec6fd1a9b7f3ef5fe0544284563d5a Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 16:22:22 +0200 Subject: [PATCH 26/37] feat: backend preflight, repeated-error escalation, and client error boundary --- lua/loopbiotic/card.lua | 7 ++ lua/loopbiotic/commands.lua | 75 +++++++++------- lua/loopbiotic/diff.lua | 5 ++ lua/loopbiotic/init.lua | 4 + lua/loopbiotic/keymaps.lua | 36 ++++++-- lua/loopbiotic/prompt.lua | 116 +++++++++++++++++++++++- lua/loopbiotic/rpc.lua | 7 +- lua/loopbiotic/session.lua | 56 ++++++++++++ lua/loopbiotic/state.lua | 6 ++ lua/loopbiotic/util.lua | 45 +++++++++- tests/lua/test_prompt.lua | 6 ++ tests/lua/test_safety.lua | 171 ++++++++++++++++++++++++++++++++++++ 12 files changed, 485 insertions(+), 49 deletions(-) create mode 100644 tests/lua/test_safety.lua diff --git a/lua/loopbiotic/card.lua b/lua/loopbiotic/card.lua index fab74a8..d307e55 100644 --- a/lua/loopbiotic/card.lua +++ b/lua/loopbiotic/card.lua @@ -144,6 +144,9 @@ function M.lines(card) M.add(lines, card.summary or card.title) elseif card.kind == "error" then M.add(lines, card.message or card.title) + for _, warning in ipairs(card.warnings or {}) do + M.signal(lines, warning) + end elseif card.kind == "deny" then table.insert(lines, "Agent could not proceed") table.insert(lines, "") @@ -497,4 +500,8 @@ function M.height(lines, width, expanded) return math.min(height, config.values.card.max_height) end +-- Error boundary: card rendering is reached from RPC callbacks and keymaps; +-- a rendering bug must log and notify, not kill the surrounding session. +M.show = util.guard("card.show", M.show) + return M diff --git a/lua/loopbiotic/commands.lua b/lua/loopbiotic/commands.lua index e7dbf12..2c1a2d1 100644 --- a/lua/loopbiotic/commands.lua +++ b/lua/loopbiotic/commands.lua @@ -1,90 +1,97 @@ +local util = require("loopbiotic.util") + local M = {} +-- Register a user command with its callback behind the error boundary, so a +-- bug in a command path is logged and reported instead of killing a session. +local function command(name, callback, opts) + opts = vim.tbl_extend("force", opts or {}, { force = true }) + vim.api.nvim_create_user_command(name, util.guard("command :" .. name, callback), opts) +end + function M.setup() - vim.api.nvim_create_user_command("Loopbiotic", function() + command("Loopbiotic", function() require("loopbiotic").prompt() - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticReply", function() + command("LoopbioticReply", function() require("loopbiotic").reply_prompt() - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticFix", function() + command("LoopbioticFix", function() M.action_or_prompt("fix", "fix") - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticWhy", function() + command("LoopbioticWhy", function() M.action_or_prompt("why", "explain") - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticFollow", function() + command("LoopbioticFollow", function() require("loopbiotic").action("follow") - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticOther", function() + command("LoopbioticOther", function() require("loopbiotic").action("other_lead") - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticAssess", function() + command("LoopbioticAssess", function() require("loopbiotic").action("next") - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticNext", function() + command("LoopbioticNext", function() require("loopbiotic").action("next") - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticStop", function() + command("LoopbioticStop", function() require("loopbiotic").stop() - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticHide", function() + command("LoopbioticHide", function() require("loopbiotic").hide() - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticResume", function() + command("LoopbioticResume", function() require("loopbiotic").resume() - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticReset", function() + command("LoopbioticReset", function() require("loopbiotic").reset() - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticLog", function() + command("LoopbioticLog", function() local log = require("loopbiotic.log") if require("loopbiotic.config").values.logging.enabled == false then print("Loopbiotic logging is disabled") else print(log.path()) end - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticLogClear", function() + command("LoopbioticLogClear", function() require("loopbiotic.log").clear() print("Loopbiotic session logs cleared") - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticBackend", function() + command("LoopbioticBackend", function() require("loopbiotic").backend() - end, { force = true }) + end) - vim.api.nvim_create_user_command("LoopbioticAgent", function(opts) + command("LoopbioticAgent", function(opts) require("loopbiotic").agent(opts.args) end, { nargs = "?", complete = function() return require("loopbiotic").agents() end, - force = true, }) - vim.api.nvim_create_user_command("LoopbioticModel", function(opts) + command("LoopbioticModel", function(opts) require("loopbiotic").model(opts.args) end, { nargs = "?", complete = function() return require("loopbiotic").models() end, - force = true, }) end diff --git a/lua/loopbiotic/diff.lua b/lua/loopbiotic/diff.lua index 77e6a3b..b4c2242 100644 --- a/lua/loopbiotic/diff.lua +++ b/lua/loopbiotic/diff.lua @@ -433,4 +433,9 @@ function M.send(accepted, patch_ids, changed_files, error) end) end +-- Error boundary: the draft preview is reached from RPC callbacks and +-- keymaps; a preview bug must log and notify, not kill the session. The +-- guarded wrapper returns nil on error, which callers treat as "not shown". +M.show = util.guard("diff.show", M.show) + return M diff --git a/lua/loopbiotic/init.lua b/lua/loopbiotic/init.lua index ee62064..7ee06e2 100644 --- a/lua/loopbiotic/init.lua +++ b/lua/loopbiotic/init.lua @@ -121,12 +121,15 @@ function M.start(text, mode, source) thinking.stop() if message.error then + -- state.prompt_stash still holds the composed text; the next + -- prompt.open pre-fills it so nothing is lost to a broken backend. log.write("session start error", message.error) ui.notify(message.error.message, vim.log.levels.ERROR) return end + state.prompt_stash = prompt.next_stash(state.prompt_stash, "start_ok") state.session_id = message.result.session_id session.apply_turn_result(message.result) end) @@ -332,6 +335,7 @@ function M.reply(text) return end + state.prompt_stash = prompt.next_stash(state.prompt_stash, "start_ok") session.apply_turn_result(message.result) end) end diff --git a/lua/loopbiotic/keymaps.lua b/lua/loopbiotic/keymaps.lua index ada3716..ea6381f 100644 --- a/lua/loopbiotic/keymaps.lua +++ b/lua/loopbiotic/keymaps.lua @@ -1,4 +1,5 @@ local config = require("loopbiotic.config") +local util = require("loopbiotic.util") local M = {} @@ -7,9 +8,16 @@ function M.setup() local key = keys.prompt if key and key ~= "" then - vim.keymap.set({ "n", "v" }, key, function() - require("loopbiotic").prompt() - end, { silent = true }) + -- Error boundary at registration: a bug behind a keymap is logged and + -- reported instead of killing the session (same for action/call below). + vim.keymap.set( + { "n", "v" }, + key, + util.guard("keymap prompt", function() + require("loopbiotic").prompt() + end), + { silent = true } + ) end M.call(keys.reply, "reply_prompt") @@ -29,9 +37,14 @@ function M.action(key, action) return end - vim.keymap.set("n", key, function() - require("loopbiotic").action(action) - end, { silent = true }) + vim.keymap.set( + "n", + key, + util.guard("keymap " .. action, function() + require("loopbiotic").action(action) + end), + { silent = true } + ) end function M.call(key, name) @@ -39,9 +52,14 @@ function M.call(key, name) return end - vim.keymap.set("n", key, function() - require("loopbiotic")[name]() - end, { silent = true }) + vim.keymap.set( + "n", + key, + util.guard("keymap " .. name, function() + require("loopbiotic")[name]() + end), + { silent = true } + ) end return M diff --git a/lua/loopbiotic/prompt.lua b/lua/loopbiotic/prompt.lua index e1015ac..da1c77c 100644 --- a/lua/loopbiotic/prompt.lua +++ b/lua/loopbiotic/prompt.lua @@ -5,8 +5,10 @@ local ui = require("loopbiotic.ui") local M = {} -- Which window kind ("Prompt"/"Reply") is currently open, so the async --- warmup response can re-render the matching frame title. +-- warmup response can re-render the matching frame title. open_footer keeps +-- the default footer so a cleared preflight error can restore it. local open_kind = "Prompt" +local open_footer = nil function M.open(mode) local source = require("loopbiotic.context").capture() @@ -24,6 +26,9 @@ function M.open(mode) require("loopbiotic").start(text, mode, source) end, }) + + M.prefill() + M.refresh_footer() end function M.reply() @@ -39,13 +44,35 @@ end -- Store the identity reported by backend/warmup and refresh the open prompt -- title with it. Old daemons answer {ok = true} without an identity field; --- tolerate that (and error responses) by keeping the previous state. +-- tolerate that by keeping the previous state. A warmup error is surfaced +-- immediately in the open prompt window's footer (and one WARN notification) +-- so the user learns the backend is broken before composing a full prompt. ---@param message table RPC response ({ result = ... } or { error = ... }) function M.on_warmup(message) - if message.error or type(message.result) ~= "table" then + if message.error then + local error_message = tostring(type(message.error) == "table" and message.error.message or message.error) + + if state.backend_preflight_error ~= error_message then + state.backend_preflight_error = error_message + ui.notify( + "Loopbiotic backend not ready: " .. error_message .. " — see :checkhealth loopbiotic", + vim.log.levels.WARN + ) + end + M.refresh_footer() + + return + end + + if type(message.result) ~= "table" then return end + if state.backend_preflight_error then + state.backend_preflight_error = nil + M.refresh_footer() + end + local identity = message.result.identity if type(identity) ~= "table" then return @@ -68,6 +95,85 @@ function M.refresh_title() end) end +-- Re-render the frame footer of the currently open prompt window, if any: +-- the preflight-error footer while a warmup failure is stored, otherwise the +-- default keymap hints. Mirrors refresh_title (schedule + validity check). +function M.refresh_footer() + vim.schedule(function() + local frame_win = state.prompt_frame_win + if not (frame_win and vim.api.nvim_win_is_valid(frame_win)) then + return + end + + local footer = open_footer + if type(state.backend_preflight_error) == "string" and state.backend_preflight_error ~= "" then + footer = M.preflight_footer(state.backend_preflight_error) + end + + pcall(vim.api.nvim_win_set_config, frame_win, { footer = footer, footer_pos = "right" }) + end) +end + +-- Footer line shown while the backend fails its warmup preflight. +---@param error_message string +---@return string +function M.preflight_footer(error_message) + return " backend not ready: " .. M.short_error(error_message) .. " — :checkhealth loopbiotic " +end + +-- One-line, footer-sized rendering of a backend error message. +---@param error_message string +---@return string +function M.short_error(error_message) + local text = tostring(error_message or ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if #text > 60 then + text = text:sub(1, 57) .. "..." + end + + return text +end + +-- Pre-fill the freshly opened prompt buffer with text stashed by a failed +-- session start, cursor at the end, so the composed prompt is not lost. +function M.prefill() + local stash = state.prompt_stash + local buf = state.prompt_buf + if type(stash) ~= "string" or stash == "" or not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + + local lines = vim.split(stash, "\n", { plain = true }) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + + local win = state.prompt_win + if win and vim.api.nvim_win_is_valid(win) then + pcall(vim.api.nvim_win_set_cursor, win, { #lines, #lines[#lines] }) + if vim.api.nvim_get_current_win() == win then + -- Append after the restored text instead of before its last character. + vim.cmd("startinsert!") + end + end +end + +-- Pure state transition for the prompt stash: submitting stashes the +-- composed text (the window is closed before the backend answers), a +-- successful start clears it, and a failed start keeps it for the next +-- prompt.open to pre-fill. +---@param stash string|nil current stash +---@param event "submit"|"start_ok"|"start_error" +---@param text string|nil submitted text (only used for "submit") +---@return string|nil next stash +function M.next_stash(stash, event, text) + if event == "submit" then + return text + end + if event == "start_ok" then + return nil + end + + return stash +end + -- Pick the concrete model out of the fixed resolution order: configured -- model, then the model the warmup identity announced for the next turn, -- then the model the backend reported after a turn. Returns nil when none @@ -183,6 +289,7 @@ end function M.open_for(opts) M.close() + open_footer = opts.footer local size = M.size() local position = M.position(size) local row = position.row @@ -276,6 +383,9 @@ function M.submit(buf, submit) vim.cmd("stopinsert") end + -- The window closes before the backend answers, so stash the composed text + -- now; a successful start clears it, a failed one leaves it for prefill. + state.prompt_stash = M.next_stash(state.prompt_stash, "submit", text) M.close() submit(text) end diff --git a/lua/loopbiotic/rpc.lua b/lua/loopbiotic/rpc.lua index 319c73e..2fa90f7 100644 --- a/lua/loopbiotic/rpc.lua +++ b/lua/loopbiotic/rpc.lua @@ -3,6 +3,7 @@ local installer = require("loopbiotic.installer") local log = require("loopbiotic.log") local state = require("loopbiotic.state") local ui = require("loopbiotic.ui") +local util = require("loopbiotic.util") local M = { job = nil, @@ -338,11 +339,13 @@ function M.handle(line) return end - vim.schedule(function() + -- Error boundary: a bug in an editor-request handler must not unwind + -- into the event loop and take the whole session down with it. + vim.schedule(util.guard("editor request " .. message.method, function() handler(message.params or {}, function(result) M.respond(id, result) end) - end) + end)) return end diff --git a/lua/loopbiotic/session.lua b/lua/loopbiotic/session.lua index 0625cd7..15256cf 100644 --- a/lua/loopbiotic/session.lua +++ b/lua/loopbiotic/session.lua @@ -3,6 +3,61 @@ local state = require("loopbiotic.state") local M = {} +-- Guidance appended to the card and the notification when the same backend +-- error arrives twice in a row. +M.repeat_guidance = "Same backend error twice — retry is unlikely to help. " + .. "Check :checkhealth loopbiotic and the agent CLI (auth/config)." + +-- Whether the current backend-error message repeats the previous one +-- exactly. Pure: only non-empty, identical consecutive messages escalate. +---@param previous string|nil message of the previous backend error card +---@param current string|nil message of the error card that just arrived +---@return boolean escalate +function M.repeated_error(previous, current) + return type(current) == "string" and current ~= "" and previous == current +end + +-- The message text of an error card (message, else title), or nil for +-- non-error cards. vim.NIL and empty strings count as no message. +---@param card table|nil +---@return string|nil +local function error_message(card) + if type(card) ~= "table" or card.kind ~= "error" then + return nil + end + + for _, value in ipairs({ card.message, card.title }) do + if type(value) == "string" and value ~= "" then + return value + end + end + + return nil +end + +-- Track consecutive identical backend-error cards. On the second identical +-- error, escalate: append a warning line to the card and raise an ERROR +-- notification with guidance, since retrying clearly is not helping. Any +-- non-error card clears the tracking (and the warmup preflight failure). +---@param card table|nil the turn-result card, mutated on escalation +local function track_backend_errors(card) + local message = error_message(card) + + if not message then + state.last_backend_error = nil + state.backend_preflight_error = nil + return + end + + if M.repeated_error(state.last_backend_error, message) then + card.warnings = card.warnings or {} + table.insert(card.warnings, M.repeat_guidance) + require("loopbiotic.ui").notify(message .. "\n" .. M.repeat_guidance, vim.log.levels.ERROR) + end + + state.last_backend_error = message +end + -- Apply the shared tail of a successful turn result (session/start, -- session/action, session/reply, patch/apply_result): record usage and -- reports, log them, adopt the updated goal, and show the resulting card. @@ -22,6 +77,7 @@ function M.apply_turn_result(result, opts) log.event("context_optimization", result.context_report or {}) log.event("agent_attempts", result.attempts or {}) state.goal = result.goal or state.goal + track_backend_errors(result.card) require("loopbiotic.card").show(result.card) end diff --git a/lua/loopbiotic/state.lua b/lua/loopbiotic/state.lua index 9b72f7c..a0b4db9 100644 --- a/lua/loopbiotic/state.lua +++ b/lua/loopbiotic/state.lua @@ -47,6 +47,9 @@ ---@field turn_token_usage LoopbioticTokenUsage|nil ---@field backend_model string|nil model the backend reported using ---@field agent_identity table|nil backend/warmup identity: { backend, model, models } +---@field backend_preflight_error string|nil last backend/warmup error; nil once a warmup or turn succeeds +---@field prompt_stash string|nil composed prompt text preserved across a failed session start +---@field last_backend_error string|nil message of the last backend error card, for repeat escalation ---@field context_report table|nil ---@field workspace_hints table[]|nil ---@field completion_notified_card string|nil @@ -93,6 +96,9 @@ local defaults = { turn_token_usage = vim.NIL, backend_model = vim.NIL, agent_identity = vim.NIL, + backend_preflight_error = vim.NIL, + prompt_stash = vim.NIL, + last_backend_error = vim.NIL, context_report = vim.NIL, workspace_hints = vim.NIL, completion_notified_card = vim.NIL, diff --git a/lua/loopbiotic/util.lua b/lua/loopbiotic/util.lua index 06f7776..6533270 100644 --- a/lua/loopbiotic/util.lua +++ b/lua/loopbiotic/util.lua @@ -1,6 +1,49 @@ --- Small shared helpers with no dependencies on other loopbiotic modules. +-- Small shared helpers with no load-time dependencies on other loopbiotic +-- modules (guard requires the log module lazily, when an error is reported). local M = {} +-- Labels whose internal error has already been reported to the user this +-- session. Every error keeps being logged; the notification fires once. +local guard_notified = {} + +local function pack(...) + return { n = select("#", ...), ... } +end + +-- Error boundary for UI entry points (RPC dispatch, card/diff rendering, +-- command and keymap callbacks). Returns a wrapped function that xpcalls fn: +-- on success it passes fn's results through; on an uncaught error it logs a +-- "client_error" event with the traceback, notifies once per label per +-- session, and returns nil — so a client-side bug cannot unwind into Neovim +-- and kill the surrounding daemon session. +---@param label string +---@param fn function +---@return function +function M.guard(label, fn) + return function(...) + local results = pack(xpcall(fn, debug.traceback, ...)) + if results[1] then + return unpack(results, 2, results.n) + end + + require("loopbiotic.log").event("client_error", { + label = label, + traceback = tostring(results[2]), + }) + + if not guard_notified[label] then + guard_notified[label] = true + vim.notify( + string.format("Loopbiotic internal error in %s (see :LoopbioticLog); session preserved", label), + vim.log.levels.ERROR, + { title = "Loopbiotic" } + ) + end + + return nil + end +end + -- The location a card points at, in priority order: an explicit next move, -- then evidence, then a plain location. ---@param card LoopbioticCard|table diff --git a/tests/lua/test_prompt.lua b/tests/lua/test_prompt.lua index 01e034c..7f67650 100644 --- a/tests/lua/test_prompt.lua +++ b/tests/lua/test_prompt.lua @@ -84,8 +84,14 @@ return function(t) t.test("on_warmup stores the identity and tolerates old daemons", function() state.agent_identity = nil + -- The preflight path now warns on error responses; keep this test about + -- identity handling (test_safety.lua covers the preflight behaviour). + local original_notify = vim.notify + vim.notify = function() end prompt.on_warmup({ error = { code = -32098, message = "stopped" } }) + vim.notify = original_notify t.eq(state.agent_identity, nil, "error response") + state.backend_preflight_error = nil prompt.on_warmup({ result = { ok = true } }) t.eq(state.agent_identity, nil, "legacy daemon without identity") diff --git a/tests/lua/test_safety.lua b/tests/lua/test_safety.lua new file mode 100644 index 0000000..2111e82 --- /dev/null +++ b/tests/lua/test_safety.lua @@ -0,0 +1,171 @@ +return function(t) + local prompt = require("loopbiotic.prompt") + local session = require("loopbiotic.session") + local state = require("loopbiotic.state") + local util = require("loopbiotic.util") + + -- Run fn with vim.notify captured into a list; always restores the original. + local function with_notify(fn) + local original = vim.notify + local notifications = {} + vim.notify = function(message, level) + table.insert(notifications, { message = message, level = level }) + end + local ok, err = pcall(fn, notifications) + vim.notify = original + if not ok then + error(err, 0) + end + end + + t.test("guard swallows errors, logs every one, notifies once per label", function() + local log = require("loopbiotic.log") + local original_event = log.event + local events = {} + log.event = function(kind, data) + table.insert(events, { kind = kind, data = data }) + end + + with_notify(function(notifications) + local calls = 0 + local wrapped = util.guard("test.boom", function() + calls = calls + 1 + error("kaboom") + end) + + t.eq(wrapped(), nil, "returns nil on error") + t.eq(wrapped(), nil, "returns nil on repeated error") + t.eq(calls, 2, "inner function ran each time") + + t.eq(#events, 2, "every error is logged") + t.eq(events[1].kind, "client_error", "event kind") + t.eq(events[1].data.label, "test.boom", "event label") + t.eq(type(events[1].data.traceback), "string", "traceback string") + t.eq(events[1].data.traceback:find("kaboom", 1, true) ~= nil, true, "traceback carries the message") + + t.eq(#notifications, 1, "notified once despite two errors") + t.eq(notifications[1].level, vim.log.levels.ERROR, "notify level") + t.eq(notifications[1].message:find("test.boom", 1, true) ~= nil, true, "notify names the label") + t.eq(notifications[1].message:find("session preserved", 1, true) ~= nil, true, "notify reassures") + + -- A different label is its own notification budget. + util.guard("test.other", function() + error("kaboom") + end)() + t.eq(#notifications, 2, "new label notifies again") + end) + + log.event = original_event + end) + + t.test("guard passes results through on success", function() + local wrapped = util.guard("test.pass", function(a, b) + return a + b, "ok" + end) + local sum, tag = wrapped(2, 3) + t.eq(sum, 5) + t.eq(tag, "ok") + end) + + t.test("repeated_error escalates only on identical consecutive messages", function() + t.eq(session.repeated_error(nil, "boom"), false, "first error") + t.eq(session.repeated_error("boom", "boom"), true, "same error twice") + t.eq(session.repeated_error("boom", "other"), false, "different error") + t.eq(session.repeated_error("boom", nil), false, "no current message") + t.eq(session.repeated_error("", ""), false, "empty messages never escalate") + t.eq(session.repeated_error(nil, nil), false, "nothing at all") + end) + + t.test("apply_turn_result escalates the second identical backend error", function() + state.reset() + local card = require("loopbiotic.card") + local original_show = card.show + local shown = {} + card.show = function(shown_card) + table.insert(shown, shown_card) + end + + local function error_result(message) + return { + session_id = "s1", + card = { + id = "e1", + kind = "error", + title = "Backend request failed", + message = message, + actions = { "retry", "edit_prompt", "stop" }, + }, + } + end + + local ok, err = pcall(function() + with_notify(function(notifications) + session.apply_turn_result(error_result("boom")) + t.eq(state.last_backend_error, "boom", "first error tracked") + t.eq(shown[1].warnings, nil, "no warning on first occurrence") + t.eq(#notifications, 0, "no escalation on first occurrence") + + session.apply_turn_result(error_result("boom")) + t.eq(#shown[2].warnings, 1, "warning appended on repeat") + t.eq(shown[2].warnings[1], session.repeat_guidance, "warning text") + t.eq(#notifications, 1, "escalated notification") + t.eq(notifications[1].level, vim.log.levels.ERROR, "escalation level") + t.eq(notifications[1].message:find("boom", 1, true) ~= nil, true, "full message included") + t.eq(notifications[1].message:find(":checkhealth loopbiotic", 1, true) ~= nil, true, "guidance included") + + session.apply_turn_result(error_result("other")) + t.eq(state.last_backend_error, "other", "tracking follows the message") + t.eq(shown[3].warnings, nil, "different message does not escalate") + + state.backend_preflight_error = "stale preflight" + session.apply_turn_result({ card = { id = "f1", kind = "finding", title = "ok" } }) + t.eq(state.last_backend_error, nil, "non-error card clears tracking") + t.eq(state.backend_preflight_error, nil, "successful turn clears the preflight error") + end) + end) + + card.show = original_show + state.reset() + if not ok then + error(err, 0) + end + end) + + t.test("on_warmup error sets the preflight state; success clears it", function() + state.reset() + with_notify(function(notifications) + prompt.on_warmup({ error = { code = -32098, message = "backend is down" } }) + t.eq(state.backend_preflight_error, "backend is down", "error stored") + t.eq(#notifications, 1, "warned once") + t.eq(notifications[1].level, vim.log.levels.WARN, "warn level") + t.eq(notifications[1].message:find("backend is down", 1, true) ~= nil, true, "error in warning") + + prompt.on_warmup({ error = { code = -32098, message = "backend is down" } }) + t.eq(#notifications, 1, "identical failure does not re-warn") + + prompt.on_warmup({ error = { code = -32098, message = "another failure" } }) + t.eq(#notifications, 2, "new failure warns again") + t.eq(state.backend_preflight_error, "another failure", "latest error stored") + + prompt.on_warmup({ result = { ok = true } }) + t.eq(state.backend_preflight_error, nil, "successful warmup clears the error") + end) + state.reset() + end) + + t.test("preflight footer is one line, truncated, and points at checkhealth", function() + local footer = prompt.preflight_footer("something\nbroke badly") + t.eq(footer, " backend not ready: something broke badly — :checkhealth loopbiotic ") + + local long = prompt.short_error(string.rep("x", 100)) + t.eq(#long, 60, "truncated to footer size") + t.eq(long:sub(-3), "...", "ellipsis") + end) + + t.test("prompt stash: set on submit, kept on failed start, cleared on success", function() + t.eq(prompt.next_stash(nil, "submit", "fix the bug"), "fix the bug", "submit stashes") + t.eq(prompt.next_stash("fix the bug", "start_error"), "fix the bug", "failed start keeps the stash") + t.eq(prompt.next_stash("fix the bug", "start_ok"), nil, "successful start clears the stash") + t.eq(prompt.next_stash("old", "submit", "new"), "new", "resubmit replaces the stash") + end) +end From 8446b8d764354abc33f29983e3daa3777a9c3bdb Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 16:44:57 +0200 Subject: [PATCH 27/37] feat: sliced goal turns with continuation speculation --- .../loopbiotic_backends/src/claude_app.rs | 40 ++- .../loopbiotic_backends/src/codex_app/mod.rs | 65 +++- .../src/codex_app/parse.rs | 3 + .../src/codex_app/schema.rs | 60 ++++ .../crates/loopbiotic_backends/src/generic.rs | 52 ++- rust/crates/loopbiotic_backends/src/mock.rs | 173 +++++++++- .../loopbiotic_harness/src/engine/goal.rs | 16 +- .../loopbiotic_harness/src/engine/mod.rs | 46 ++- .../loopbiotic_harness/src/engine/prefetch.rs | 119 ++++++- .../loopbiotic_harness/src/engine/tests.rs | 325 ++++++++++++++++++ .../loopbiotic_harness/src/engine/turn.rs | 4 +- rust/crates/loopbiotic_harness/src/session.rs | 5 + rust/crates/loopbiotic_harness/src/state.rs | 1 + rust/crates/loopbiotic_patch/src/validate.rs | 11 + rust/crates/loopbiotic_protocol/src/agent.rs | 35 +- rust/crates/loopbiotic_protocol/src/card.rs | 85 +++++ schemas/loopbiotic-agent-op.schema.json | 20 ++ 17 files changed, 1007 insertions(+), 53 deletions(-) diff --git a/rust/crates/loopbiotic_backends/src/claude_app.rs b/rust/crates/loopbiotic_backends/src/claude_app.rs index bce6c04..8aeaa77 100644 --- a/rust/crates/loopbiotic_backends/src/claude_app.rs +++ b/rust/crates/loopbiotic_backends/src/claude_app.rs @@ -25,7 +25,7 @@ Every user message is a JSON Loopbiotic request. Reply with exactly one JSON Loo The discriminator field is named "op". Allowed ops, with exact shapes: - {"op":"hypothesis","title":string,"claim":string,"evidence":LOC|null,"next":LOC|null} - {"op":"finding","title":string,"finding":string,"location":LOC|null,"annotation":string|null} -- {"op":"patch","title":string,"explanation":string,"goal_complete":bool,"patches":[{"id":string|null,"file":string,"diff":string,"explanation":string}]} +- {"op":"patch","title":string,"explanation":string,"goal_complete":bool,"plan":{"remaining":[{"file":string,"summary":string}],"complete":bool}|null,"patches":[{"id":string|null,"file":string,"diff":string,"explanation":string}]} - {"op":"choice","title":string,"question":string,"options":[{"id":string,"label":string,"action":string}]} - {"op":"deny","title":string,"reason":string,"location":LOC|null} - {"op":"open_location","reason":string,"location":LOC} @@ -37,9 +37,9 @@ Use deny when you cannot or should not proceed (ambiguous prompt, missing inform If you can only proceed from a different file or location — for example the change belongs in another file than the supplied buffer — return open_location IMMEDIATELY with that exact place instead of attempting a patch. The editor asks the user for permission, opens the file, and the next message continues this same turn with a.kind "location_granted" and fresh ctx for that buffer; then produce the real op. Never draft a patch against a file that is not the supplied buffer. Use deny only for refusals that navigation cannot solve. limits.expected, when set, names the op you must return (deny is always allowed instead; a clarifying choice is also accepted for hypothesis and finding). When limits.expected is null, choose whichever op fits best and ask via choice when the request is ambiguous. Patch only for fix actions or when limits.goal_completion is true. patch.diff must be unified diff hunks starting with @@ against the corresponding project source. -When limits.goal_completion is true, drive the original goal from start to finish in one work turn. Inspect every required file with read-only tools and return the complete multi-file patch batch within limits.patch_files, limits.hunks_per_patch, and limits.changed_lines. Tool reads are valid patch source because Loopbiotic verifies every hunk against the live editor buffer before review. Loopbiotic reviews the batch locally without another model turn. Create missing files directly in the same batch. Set goal_complete=true when accepting the complete batch finishes the original goal. Use open_location only when a required source cannot be inspected. Return summary only when every stated requirement was already satisfied. Continue automatically from completed_steps and never repeat accepted work. +When limits.goal_completion is true, drive the original goal one file slice per work turn: return exactly ONE file's complete patch — the most foundational unresolved file first — plus plan listing the remaining files (file + one-line summary each) and complete. Set plan.complete=true only on the final slice; remaining is then empty. Inspect the file with read-only tools; tool reads are valid patch source because Loopbiotic verifies every hunk against the live editor buffer before review. Loopbiotic reviews the slice locally, then asks for the next planned slice. Stay within limits.hunks_per_patch and limits.changed_lines for that one file. Create a missing file as its own slice before slices that reference it. Set goal_complete=true only together with plan.complete=true. Use open_location only when a required source cannot be inspected. Return summary only when every stated requirement was already satisfied. Continue automatically from completed_steps and never repeat accepted work. When limits.goal_completion is true and limits.expected is finding because the user asked why, explain the currently pending hunk without replacing it or advancing the goal. The same draft remains pending after the answer. -A non-goal patch is one small local pair-programming step: one file, one hunk, no more changed lines than the supplied limit. A goal patch contains the complete change for the current buffer and may contain multiple hunks within the supplied limits. +A non-goal patch is one small local pair-programming step: one file, one hunk, no more changed lines than the supplied limit; its plan is null. A goal patch contains the complete change for one file and may contain multiple hunks within the supplied limits. Prefer the supplied context; you may use at most two targeted read-only searches when it is insufficient. Never edit files or run commands."#; /// Keeps `claude` CLI processes alive across turns using its stream-json @@ -755,6 +755,8 @@ impl BackendAdapter for ClaudeAppBackend { } fn turn_prompt(req: &BackendRequest, include_context: bool) -> String { + let goal_turn = req.card_contract.allow_goal_completion + && req.card_contract.expected_kind != Some(loopbiotic_protocol::CardKind::Finding); let mut value = json!({ "s": { "id": req.session.id, @@ -777,6 +779,17 @@ fn turn_prompt(req: &BackendRequest, include_context: bool) -> String { } }); + if goal_turn { + value["slice"] = json!(if matches!( + req.action, + crate::BackendAction::User(loopbiotic_protocol::Action::Next) + ) { + "Continue with the next planned file slice: one file's complete patch plus the refreshed plan." + } else { + "Return exactly one file's complete patch (the most foundational unresolved file first) plus plan {remaining:[{file,summary}],complete}." + }); + } + if include_context { value["ctx"] = crate::backend_context(&req.context); } else { @@ -1176,6 +1189,27 @@ mod tests { ); } + #[test] + fn turn_prompt_adds_the_slice_instruction_only_on_goal_turns() { + let mut req = crate::test_request(); + assert!(!turn_prompt(&req, true).contains("\"slice\"")); + + req.card_contract.allow_goal_completion = true; + req.card_contract.expected_kind = None; + let goal = turn_prompt(&req, true); + assert!(goal.contains("exactly one file's complete patch")); + assert!(!goal.contains("next planned file slice")); + + req.action = crate::BackendAction::User(loopbiotic_protocol::Action::Next); + let continuation = turn_prompt(&req, true); + assert!(continuation.contains("Continue with the next planned file slice")); + + // The goal "why" turn explains the pending hunk; it must not be asked + // to produce another slice. + req.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Finding); + assert!(!turn_prompt(&req, true).contains("\"slice\"")); + } + #[test] fn turn_prompt_omits_unchanged_context() { let req = crate::test_request(); diff --git a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs index 380568c..9e490a1 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs @@ -165,7 +165,7 @@ impl CodexAppBackend { "You are a local Loopbiotic pair-programming partner. You may use at most two targeted read-only project tool calls to find the next relevant code block. Stop searching once the supplied context supports an exact location. Never edit files. Return exactly one final JSON object matching the supplied output schema and no prose." }; let developer_instructions = if goal_loop { - "Drive the original goal from start to finish. In one work turn inspect every required file and return the complete multi-file patch batch; Loopbiotic reviews its hunks locally. When the user asks why, explain the pending hunk without advancing or replacing it. Preserve progress across turns and do not repeat accepted work." + "Drive the original goal from start to finish, one file slice per work turn: return exactly one file's complete patch plus a plan of the remaining files, and continue with the next planned slice when asked; Loopbiotic reviews each slice's hunks locally. When the user asks why, explain the pending hunk without advancing or replacing it. Preserve progress across turns and do not repeat accepted work." } else if patch_turn { "Work as an equal pair-programming partner. Propose one coherent local block at the supplied location and explain why this is the useful next move. Do not take over the whole task. Return one structured patch hunk as an editable draft, not a finished agenda." } else { @@ -417,20 +417,28 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { - The exact pending patch remains awaiting user acceptance after this answer." .into() } else if goal_loop { + let lead = if matches!( + req.action, + crate::BackendAction::User(loopbiotic_protocol::Action::Next) + ) { + "- Continue with the next planned file slice; the previous slice was accepted.\n" + } else { + "" + }; format!( - "- Continue executing the original session goal from the accepted progress; never restart or repeat a completed step.\n\ - - Inspect every required project file with targeted read-only tools and prepare the complete change in this turn.\n\ - - Tool reads are valid patch source. Loopbiotic verifies every returned hunk against the corresponding live editor buffer before review.\n\ - - Return one structured patch batch with up to {} files, up to {} hunks per file, and at most {} added/removed lines per hunk. Include every required edit; review granularity is handled locally.\n\ - - Create missing files directly in the same batch before patches that reference them.\n\ + "{lead}\ + - Continue executing the original session goal from the accepted progress; never restart or repeat a completed step.\n\ + - Return exactly one file's complete patch in this response: the most foundational unresolved file first. Never batch a second file into the same response.\n\ + - Inspect that file with targeted read-only tools. Tool reads are valid patch source: Loopbiotic verifies every returned hunk against the corresponding live editor buffer before review.\n\ + - The file's patch may use up to {} hunks and at most {} added/removed lines per hunk.\n\ + - With the patch return plan: the remaining files that still need their own slice, each with a one-line summary, plus complete. Set complete=true only when this patch is the final slice; remaining is then empty.\n\ + - Create a missing file as its own slice before the slices that reference it.\n\ - Use open_location only when a required source cannot be inspected with read-only project tools.\n\ - - Set goal_complete=true when accepting the complete returned patch finishes the original goal. Set it false only when another file or independently inspected stage remains.\n\ + - Set goal_complete=true only together with plan.complete=true.\n\ - Return summary only when every requirement in the original goal is satisfied; cite the completed result.\n\ - Return choice only when a genuine user decision blocks all safe progress.\n\ - - Do not return a finding, an assessment, a plan, or instructions for the user to request another draft.", - req.card_contract.max_patch_files, - req.card_contract.max_hunks_per_patch, - req.card_contract.max_changed_lines, + - Do not return a finding, an assessment, or instructions for the user to request another draft.", + req.card_contract.max_hunks_per_patch, req.card_contract.max_changed_lines, ) } else if patch_turn { format!( @@ -458,7 +466,7 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { let output_contract = if goal_question { "- finding: concise explanation of the pending hunk" } else if goal_loop { - "- patch: one complete structured patch for local hunk-by-hunk review; include goal_complete\n\ + "- patch: one file's complete structured patch for local hunk-by-hunk review; include goal_complete and plan {remaining: [{file, summary}], complete}\n\ - open_location: when the next hunk belongs in another buffer; put the target in location (not next) and the explanation in reason (not message)\n\ - choice: only for a blocking user decision\n\ - summary: only when the complete original goal is satisfied" @@ -694,14 +702,41 @@ mod tests { } #[test] - fn goal_prompt_requests_one_verified_multi_file_batch() { + fn goal_prompt_requests_one_file_slice_with_a_plan() { let mut request = request(); request.card_contract.allow_goal_completion = true; request.card_contract.expected_kind = None; let prompt = prompt(&request, true); assert!(prompt.contains("Tool reads are valid patch source")); - assert!(prompt.contains("complete change in this turn")); - assert!(prompt.contains("Create missing files directly in the same batch")); + assert!(prompt.contains("exactly one file's complete patch")); + assert!(prompt.contains("With the patch return plan")); + assert!(prompt.contains("complete=true only when this patch is the final slice")); + assert!(!prompt.contains("Continue with the next planned file slice")); + } + + #[test] + fn goal_continuation_prompt_asks_for_the_next_planned_slice() { + let mut request = request(); + request.card_contract.allow_goal_completion = true; + request.card_contract.expected_kind = None; + request.action = BackendAction::User(Action::Next); + let prompt = prompt(&request, true); + + assert!(prompt.contains("Continue with the next planned file slice")); + assert!(prompt.contains("exactly one file's complete patch")); + } + + #[test] + fn non_goal_prompts_omit_the_slice_instruction() { + let mut request = request(); + request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Patch); + + let patch_prompt = prompt(&request, true); + assert!(!patch_prompt.contains("With the patch return plan")); + + request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Hypothesis); + let discovery_prompt = prompt(&request, true); + assert!(!discovery_prompt.contains("exactly one file's complete patch")); } } diff --git a/rust/crates/loopbiotic_backends/src/codex_app/parse.rs b/rust/crates/loopbiotic_backends/src/codex_app/parse.rs index 06eb5eb..c2e2a1c 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/parse.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/parse.rs @@ -13,6 +13,8 @@ struct StructuredPatchOp { explanation: String, #[serde(default)] goal_complete: bool, + #[serde(default)] + plan: Option, patches: Vec, } @@ -87,6 +89,7 @@ fn parse_structured_patch(output: &str) -> Result { explanation: op.explanation, warnings: vec![], goal_complete: op.goal_complete, + plan: op.plan, patches, actions: vec![ Action::Apply, diff --git a/rust/crates/loopbiotic_backends/src/codex_app/schema.rs b/rust/crates/loopbiotic_backends/src/codex_app/schema.rs index 6d621b7..213c0e6 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/schema.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/schema.rs @@ -110,9 +110,36 @@ fn goal_loop_schema(contract: &crate::CardContract) -> Value { let mut patches = patch_schema(contract)["properties"]["patches"].clone(); patches["type"] = json!(["array", "null"]); schema["properties"]["patches"] = patches; + // Sliced goal turns return one file's patch plus the plan of remaining + // slices; null keeps legacy full-batch responses and non-patch ops legal. + schema["properties"]["plan"] = json!({ + "anyOf": [plan_schema(), {"type": "null"}] + }); + if let Some(required) = schema["required"].as_array_mut() { + required.push(json!("plan")); + } schema } +fn plan_schema() -> Value { + object_schema( + &["remaining", "complete"], + json!({ + "remaining": { + "type": "array", + "items": object_schema( + &["file", "summary"], + json!({ + "file": {"type": "string"}, + "summary": {"type": "string"} + }) + ) + }, + "complete": {"type": "boolean"} + }), + ) +} + fn object_schema(required: &[&str], properties: Value) -> Value { json!({ "type": "object", @@ -327,6 +354,39 @@ mod tests { assert!(schema["properties"]["goal_complete"].is_object()); } + #[test] + fn goal_loop_schema_carries_the_slice_plan() { + let schema = goal_loop_schema(&crate::CardContract::default()); + + let plan = &schema["properties"]["plan"]["anyOf"][0]; + assert_eq!(plan["properties"]["complete"]["type"], "boolean"); + assert_eq!( + plan["properties"]["remaining"]["items"]["properties"]["file"]["type"], + "string" + ); + assert_eq!( + plan["properties"]["remaining"]["items"]["properties"]["summary"]["type"], + "string" + ); + assert!( + schema["required"] + .as_array() + .unwrap() + .contains(&json!("plan")) + ); + // Legacy responses may omit the plan by sending null. + assert_eq!(schema["properties"]["plan"]["anyOf"][1]["type"], "null"); + } + + #[test] + fn non_goal_schemas_omit_the_plan_field() { + let patch = patch_schema(&crate::CardContract::default()); + assert!(patch["properties"].get("plan").is_none()); + + let any = any_op_schema(); + assert!(any["properties"].get("plan").is_none()); + } + #[test] fn discovery_schema_requires_a_concrete_next_location() { let schema = hypothesis_schema(); diff --git a/rust/crates/loopbiotic_backends/src/generic.rs b/rust/crates/loopbiotic_backends/src/generic.rs index 84aec8c..4f14d15 100644 --- a/rust/crates/loopbiotic_backends/src/generic.rs +++ b/rust/crates/loopbiotic_backends/src/generic.rs @@ -64,8 +64,36 @@ impl GenericCliBackend { } pub(crate) fn generic_prompt(req: &BackendRequest) -> String { + let mut rules = vec![ + json!( + "If a.kind is user and a.action is fix, return a patch op unless a patch is impossible." + ), + json!( + "If s.mode is fix and a.kind is start, return a patch op unless a patch is impossible." + ), + json!( + "For non-fix actions, do not return a patch op unless limits.goal_completion is true." + ), + json!( + "A non-goal patch is one small local pair-programming step: one file, one hunk, and no more changed lines than the supplied limit; its plan is null." + ), + json!( + "Explain why the next coherent block matters and return control to the user after that step." + ), + ]; + if req.card_contract.allow_goal_completion { + rules.push(json!( + "Goal turn: return exactly one file's complete patch per response — the most foundational unresolved file first — plus plan {remaining:[{file,summary}],complete}. Set plan.complete true only on the final slice; remaining is then empty. Use up to limits.hunks_per_patch hunks and limits.changed_lines per hunk for that one file; Loopbiotic verifies and reviews the slice locally, then asks for the next planned slice." + )); + rules.push(json!( + "On a goal continuation (a.action is next), continue with the next planned file slice." + )); + rules.push(json!( + "If limits.goal_completion is true and limits.expected is finding, explain why the pending hunk is the right next step without replacing it or advancing the goal." + )); + } let value = json!({ - "api": "Return one JSON Loopbiotic op only. No prose. Ops: hypothesis(title,claim,evidence,next), finding(title,finding,location,annotation), patch(title,explanation,goal_complete,patches), choice(title,question,options), deny(title,reason,location), open_location(reason,location), summary(title,summary,changed_files), error(title,message). choice.options items are {id,label,action} objects; action is one of follow|why|fix|other_lead|retry|edit_prompt|open|run_check|next|stop. Use deny when you cannot or should not proceed (ambiguous prompt, missing information, out-of-scope request); reason is shown to the user. Outside goal completion, return open_location when the change belongs in a different file than the supplied buffer. Goal batches may patch every file inspected with read-only tools. error is only for technical failures. limits.expected, when set, is the required op (deny always allowed; choice also allowed for hypothesis/finding); when null, choose the best fitting op and ask via choice when ambiguous. Patch only for fix. patch.diff must be unified diff hunks starting with @@. Unused schema fields null.", + "api": "Return one JSON Loopbiotic op only. No prose. Ops: hypothesis(title,claim,evidence,next), finding(title,finding,location,annotation), patch(title,explanation,goal_complete,plan,patches), choice(title,question,options), deny(title,reason,location), open_location(reason,location), summary(title,summary,changed_files), error(title,message). choice.options items are {id,label,action} objects; action is one of follow|why|fix|other_lead|retry|edit_prompt|open|run_check|next|stop. Use deny when you cannot or should not proceed (ambiguous prompt, missing information, out-of-scope request); reason is shown to the user. Outside goal completion, return open_location when the change belongs in a different file than the supplied buffer. Goal slices may patch any file inspected with read-only tools. error is only for technical failures. limits.expected, when set, is the required op (deny always allowed; choice also allowed for hypothesis/finding); when null, choose the best fitting op and ask via choice when ambiguous. Patch only for fix. patch.diff must be unified diff hunks starting with @@. Unused schema fields null.", "stream": { "protocol": "ndjson", "progress": {"t": "loopbiotic_progress", "phase": "short phase", "message": "short user-visible activity summary"}, @@ -76,15 +104,7 @@ pub(crate) fn generic_prompt(req: &BackendRequest) -> String { "The final output may instead be a raw Loopbiotic op for backwards compatibility." ] }, - "rules": [ - "If a.kind is user and a.action is fix, return a patch op unless a patch is impossible.", - "If s.mode is fix and a.kind is start, return a patch op unless a patch is impossible.", - "For non-fix actions, do not return a patch op unless limits.goal_completion is true.", - "If limits.goal_completion is true, inspect every required file and return the complete multi-file patch batch in one turn. Use up to limits.patch_files files, limits.hunks_per_patch hunks per file, and limits.changed_lines per hunk; Loopbiotic verifies and reviews them locally. Set goal_complete true when accepting the batch finishes the goal.", - "If limits.goal_completion is true and limits.expected is finding, explain why the pending hunk is the right next step without replacing it or advancing the goal.", - "A non-goal patch is one small local pair-programming step: one file, one hunk, and no more changed lines than the supplied limit.", - "Explain why the next coherent block matters and return control to the user after that step." - ], + "rules": rules, "limits": { "one": req.card_contract.one_card_only, "max": req.card_contract.max_body_chars, @@ -345,6 +365,18 @@ fn first_json_object(output: &str) -> Option<&str> { mod tests { use super::*; + #[test] + fn generic_prompt_adds_the_slice_rule_only_on_goal_turns() { + let mut req = crate::test_request(); + assert!(!generic_prompt(&req).contains("exactly one file's complete patch")); + + req.card_contract.allow_goal_completion = true; + let goal = generic_prompt(&req); + assert!(goal.contains("exactly one file's complete patch")); + assert!(goal.contains("plan {remaining:[{file,summary}],complete}")); + assert!(goal.contains("next planned file slice")); + } + #[test] fn extracts_json_card() { let output = "text {\"kind\":\"error\",\"id\":\"c_1\",\"title\":\"Nope\",\"message\":\"bad\",\"actions\":[\"retry\",\"stop\"]} tail"; diff --git a/rust/crates/loopbiotic_backends/src/mock.rs b/rust/crates/loopbiotic_backends/src/mock.rs index 4561132..774ef3d 100644 --- a/rust/crates/loopbiotic_backends/src/mock.rs +++ b/rust/crates/loopbiotic_backends/src/mock.rs @@ -1,8 +1,8 @@ use anyhow::Result; use async_trait::async_trait; use loopbiotic_protocol::{ - Action, BackendInfo, Card, ErrorCard, FilePatch, FindingCard, HypothesisCard, PatchCard, - SummaryCard, TokenUsage, + Action, BackendInfo, Card, ErrorCard, FilePatch, FindingCard, GoalPlan, HypothesisCard, + PatchCard, PlannedStep, SummaryCard, TokenUsage, }; use serde_json::to_string; @@ -73,26 +73,166 @@ impl BackendAdapter for MockBackend { } } +/// Later slices of the mock's three-slice goal. The first slice always +/// targets the live buffer; these two use fixed single-line sources so tests +/// (and the daemon's editor/read_file flow) can serve matching buffers. +const MOCK_SLICES: &[(&str, &str, &str, &str)] = &[ + ( + "src/caller.ts", + "caller", + "CALLER", + "Update the consumer to read the new payload shape.", + ), + ( + "src/shape.ts", + "shape", + "SHAPE", + "Align the shared shape declaration with the new payload.", + ), +]; + +/// Emulates a sliced three-slice goal: the current buffer first (plan lists +/// the two remaining files), then each planned file in turn, the last slice +/// marked complete. A retry reworks the pending slice instead of advancing. fn goal_card(req: &BackendRequest) -> Card { - if req.session.completed_steps.len() >= 2 { - Card::Summary(SummaryCard { + let last_plan = match &req.session.last_card { + Some(Card::Patch(card)) => card.plan.as_ref(), + _ => None, + }; + + if matches!(req.action, BackendAction::User(Action::Retry)) { + if let Some(Card::Patch(card)) = &req.session.last_card { + return rework_card(card); + } + } + + let Some(plan) = last_plan else { + // A fresh goal turn: slice the live buffer first. + return slice_card( + "c_slice_1", + patch_card(req), + GoalPlan { + remaining: MOCK_SLICES + .iter() + .map(|(file, _, _, summary)| PlannedStep { + file: (*file).into(), + summary: (*summary).into(), + }) + .collect(), + complete: false, + }, + ); + }; + + let Some(next) = plan.remaining.first() else { + return Card::Summary(SummaryCard { id: "c_complete".into(), title: "Goal complete".into(), - summary: "The payload and its caller now preserve the required shape.".into(), + summary: "The payload and its callers now preserve the required shape.".into(), changed_files: vec!["src/work.ts".into()], next_actions: vec![Action::RunCheck, Action::Stop], + }); + }; + let Some((file, source, replacement, summary)) = MOCK_SLICES + .iter() + .find(|(file, _, _, _)| *file == next.file) + else { + return unsupported_card(Action::Next); + }; + + let remaining = plan + .remaining + .iter() + .skip(1) + .cloned() + .collect::>(); + let index = MOCK_SLICES.len() - plan.remaining.len() + 2; + slice_card( + &format!("c_slice_{index}"), + Card::Patch(PatchCard { + id: String::new(), + title: format!("Slice {index}: {file}"), + explanation: (*summary).into(), + warnings: vec![], + goal_complete: false, + plan: None, + patches: vec![FilePatch { + id: format!("p_slice_{index}"), + file: (*file).into(), + diff: format!("@@ -1,1 +1,1 @@\n-{source}\n+{replacement}\n"), + explanation: (*summary).into(), + }], + actions: goal_actions(), + }), + GoalPlan { + complete: remaining.is_empty(), + remaining, + }, + ) +} + +fn slice_card(id: &str, card: Card, plan: GoalPlan) -> Card { + let Card::Patch(mut card) = card else { + return card; + }; + card.id = id.into(); + card.goal_complete = false; + card.plan = Some(plan); + Card::Patch(card) +} + +/// Reworks the pending slice in place: same file and plan, alternated line so +/// the rework is visibly different from the rejected draft. +fn rework_card(card: &PatchCard) -> Card { + let patch = &card.patches[0]; + let flipped = patch + .diff + .lines() + .map(|line| match line.split_at_checked(1) { + Some(("+", text)) => format!("+{}", flip_case(text)), + _ => line.to_string(), }) - } else { - let mut card = patch_card(req); - if req.session.completed_steps.len() == 1 - && let Card::Patch(patch) = &mut card - { - patch.title = "Complete payload shape".into(); - patch.explanation = "Add the data member required by the caller.".into(); - patch.patches[0].explanation = "Provides the caller-visible data member.".into(); - } - card - } + .collect::>() + .join("\n") + + "\n"; + + Card::Patch(PatchCard { + id: format!("{}_rework", card.id), + title: card.title.clone(), + explanation: format!("Rework: {}", card.explanation), + warnings: vec![], + goal_complete: false, + plan: card.plan.clone(), + patches: vec![FilePatch { + id: format!("{}_rework", patch.id), + file: patch.file.clone(), + diff: flipped, + explanation: format!("Rework: {}", patch.explanation), + }], + actions: goal_actions(), + }) +} + +fn flip_case(text: &str) -> String { + text.chars() + .map(|character| { + if character.is_uppercase() { + character.to_ascii_lowercase() + } else { + character.to_ascii_uppercase() + } + }) + .collect() +} + +fn goal_actions() -> Vec { + vec![ + Action::Apply, + Action::Why, + Action::Retry, + Action::EditPrompt, + Action::Stop, + ] } impl MockBackend { @@ -208,6 +348,7 @@ fn patch_card(req: &BackendRequest) -> Card { explanation: "Ensure the empty branch returns the same payload shape.".into(), warnings: vec![], goal_complete: req.card_contract.allow_goal_completion, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: relative_file(req).into(), diff --git a/rust/crates/loopbiotic_harness/src/engine/goal.rs b/rust/crates/loopbiotic_harness/src/engine/goal.rs index 28b7359..fa8707d 100644 --- a/rust/crates/loopbiotic_harness/src/engine/goal.rs +++ b/rust/crates/loopbiotic_harness/src/engine/goal.rs @@ -109,9 +109,22 @@ pub(super) fn completed_patch_steps(session: &Session) -> Vec { pub(super) fn queue_goal_patch_cards(session: &mut Session, card: Card) -> Result { let Card::Patch(card) = card else { session.pending_patch_cards.clear(); + session.goal_slice_continues = false; return Ok(card); }; + // A sliced goal response: exactly one file plus a plan of what remains. + // Multi-file responses (or responses without a plan) keep the legacy + // full-batch semantics, where `goal_complete` alone decides completion. + let slice_plan = (card.patches.len() == 1) + .then(|| card.plan.clone()) + .flatten(); + let completes = slice_plan + .as_ref() + .map(|plan| plan.complete) + .unwrap_or(card.goal_complete); + session.goal_slice_continues = slice_plan.as_ref().is_some_and(|plan| !plan.complete); + let mut cards = Vec::new(); for patch in card.patches { let diff = loopbiotic_patch::UnifiedDiff::parse(&patch.diff)?; @@ -133,6 +146,7 @@ pub(super) fn queue_goal_patch_cards(session: &mut Session, card: Card) -> Resul explanation: explanation.clone(), warnings: card.warnings.clone(), goal_complete: false, + plan: slice_plan.clone(), patches: vec![loopbiotic_protocol::FilePatch { id: format!("{}_h{}", patch.id, index + 1), file: patch.file.clone(), @@ -149,7 +163,7 @@ pub(super) fn queue_goal_patch_cards(session: &mut Session, card: Card) -> Resul .next() .ok_or_else(|| anyhow!("goal patch contains no reviewable hunks"))?; let mut pending = cards.collect::>(); - if card.goal_complete { + if completes { if let Some(last) = pending.back_mut() { last.goal_complete = true; } else { diff --git a/rust/crates/loopbiotic_harness/src/engine/mod.rs b/rust/crates/loopbiotic_harness/src/engine/mod.rs index 3b8aec9..41cdceb 100644 --- a/rust/crates/loopbiotic_harness/src/engine/mod.rs +++ b/rust/crates/loopbiotic_harness/src/engine/mod.rs @@ -32,7 +32,7 @@ use goal::{ queue_goal_patch_cards, update_goal_state, }; use observations::{observation_prompt_line, prepare_observation_card, record_observations}; -use prefetch::Prefetch; +use prefetch::{Continuation, Prefetch}; use validate::validate_apply_result; pub struct Engine { @@ -41,6 +41,14 @@ pub struct Engine { context_optimizer: ContextOptimizer, prefetch_mode: PrefetchMode, prefetches: HashMap, + /// In-flight speculative goal-continuation turns, keyed by session. + continuations: HashMap, + /// Cancelled continuations still running on the backend; their usage is + /// folded into the session totals once they finish. + cancelled_continuations: Vec<( + String, + tokio::task::JoinHandle>, + )>, location_granter: Option, source_context_provider: Option, } @@ -81,6 +89,8 @@ impl Engine { context_optimizer: ContextOptimizer::default(), prefetch_mode: PrefetchMode::Off, prefetches: HashMap::new(), + continuations: HashMap::new(), + cancelled_continuations: vec![], location_granter: None, source_context_provider: None, } @@ -138,6 +148,7 @@ impl Engine { self.sessions.insert(session_id.clone(), session); self.schedule_prefetch(&session_id).await; + self.schedule_goal_continuation(&session_id).await; Ok(StartSessionResult { session_id, @@ -162,6 +173,11 @@ impl Engine { progress: Option, ) -> Result { let mut session = self.take_session(session_id)?; + if matches!(action, Action::Retry | Action::EditPrompt | Action::Stop) { + // These actions abandon the pending slice, so a speculated + // continuation built on top of it can only be wasted work. + self.cancel_goal_continuation(&mut session).await; + } let prefetched = self.take_prefetch(&mut session, &action).await; let result = self .action_taken(session_id, &mut session, action, progress, prefetched) @@ -170,6 +186,7 @@ impl Engine { self.sessions.insert(session_id.into(), session); if result.is_ok() { self.schedule_prefetch(session_id).await; + self.schedule_goal_continuation(session_id).await; } result @@ -186,6 +203,9 @@ impl Engine { progress: Option, ) -> Result { let mut session = self.take_session(session_id)?; + // A reply reworks or replaces whatever is pending, so a speculated + // continuation of the current slice is stale. + self.cancel_goal_continuation(&mut session).await; let result = self .reply_taken(session_id, &mut session, text, progress) .await; @@ -193,6 +213,7 @@ impl Engine { self.sessions.insert(session_id.into(), session); if result.is_ok() { self.schedule_prefetch(session_id).await; + self.schedule_goal_continuation(session_id).await; } result @@ -235,7 +256,10 @@ impl Engine { if session.state == SessionState::PatchShown && matches!(action, Action::Retry | Action::EditPrompt) { + // The redraft replaces the pending slice chain, so there is no + // longer a planned continuation to speculate on. session.pending_patch_cards.clear(); + session.goal_slice_continues = false; } let state = session.state.next(&action)?; @@ -363,7 +387,13 @@ impl Engine { .apply_result_taken(&mut session, result, progress) .await; - self.sessions.insert(session_id, session); + self.sessions.insert(session_id.clone(), session); + if output.is_ok() { + // Covers both the next slice arriving (consumed speculation or a + // real turn) and the rework after a reject: once the new slice is + // under review, speculate on its continuation. + self.schedule_goal_continuation(&session_id).await; + } output } @@ -422,12 +452,17 @@ impl Engine { if completes_goal { return Ok(complete_goal_locally(&session_id, session)); } + // The last queued hunk was accepted: consume the slice that + // was speculated while the user reviewed (awaiting it if it is + // still generating); without one, run the turn for real. + let speculated = self.take_goal_continuation(&session_id).await; return self .goal_turn_taken( &session_id, session, BackendAction::User(Action::Next), progress, + speculated, ) .await; } @@ -469,6 +504,9 @@ impl Engine { session.rejected_patches.extend(result.patch_ids.clone()); session.pending_patch_cards.clear(); session.state = SessionState::PatchShown; + // The rejected slice is being reworked, so the continuation speculated + // from it is stale; its usage still folds into the session totals. + self.cancel_goal_continuation(session).await; if session.continuous_goal { return self @@ -477,6 +515,7 @@ impl Engine { session, BackendAction::User(Action::Retry), progress, + None, ) .await; } @@ -491,12 +530,13 @@ impl Engine { session: &mut Session, action: BackendAction, progress: Option, + speculated: Option, ) -> Result { let expected = NextState::GoalLoop; let context = session.context.clone(); session.state = SessionState::Thinking; let response = self - .next_distinct_response(session, action, context, &expected, progress, None) + .next_distinct_response(session, action, context, &expected, progress, speculated) .await; let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); let attempts = response.metadata.attempts.clone(); diff --git a/rust/crates/loopbiotic_harness/src/engine/prefetch.rs b/rust/crates/loopbiotic_harness/src/engine/prefetch.rs index 4f34e66..b30c944 100644 --- a/rust/crates/loopbiotic_harness/src/engine/prefetch.rs +++ b/rust/crates/loopbiotic_harness/src/engine/prefetch.rs @@ -1,12 +1,14 @@ -//! Speculative prefetch of the likely next card while the user is still -//! reading the current one. +//! Speculative work while the user is still reading the current card: the +//! Fix prefetch of the likely next card and the goal-continuation prefetch of +//! the next sliced goal turn. Both are session-keyed background turns whose +//! usage folds into the session totals even when the speculation is wasted. use anyhow::Result; use loopbiotic_backends::{BackendAction, BackendRequest, BackendResponse}; use loopbiotic_protocol::Action; use crate::session::Session; -use crate::state::SessionState; +use crate::state::{NextState, SessionState}; use super::Engine; @@ -25,6 +27,22 @@ pub(super) struct Prefetch { handle: tokio::task::JoinHandle>, } +/// A speculative goal-continuation turn: while the user reviews the hunks of +/// one goal slice, the next slice is already being generated on the same +/// backend session. Unlike the Fix prefetch it is not fingerprint-matched — +/// accepting hunks necessarily changes the context, and the consumed response +/// still passes every validation gate against the live buffers. +pub(super) struct Continuation { + handle: tokio::task::JoinHandle>, +} + +#[cfg(test)] +impl Continuation { + pub(super) fn is_finished(&self) -> bool { + self.handle.is_finished() + } +} + impl Engine { /// Requests the likely next card in the background while the user reads /// the one just shown. Only Fix is predicted: it is the most common and @@ -132,6 +150,101 @@ impl Engine { None } + + /// Speculatively requests the next goal slice in the background while the + /// user still reviews the current slice's hunks. Only fires when the + /// pending slice's plan says more slices follow; consumed by + /// `take_goal_continuation` once the review queue drains. + pub(super) async fn schedule_goal_continuation(&mut self, session_id: &str) { + self.reap_cancelled_continuations().await; + + let Some(session) = self.sessions.get(session_id) else { + return; + }; + if !session.continuous_goal + || !session.goal_slice_continues + || session.state != SessionState::PatchShown + { + return; + } + if self.continuations.contains_key(session_id) { + // The running speculation already targets the next slice of this + // chain; queueing another would only pile up turns. + return; + } + + // The same request the engine would send once the last queued hunk is + // accepted, built from the pre-accept snapshot: sliced backends keep + // per-session threads, so the continuation lands on the same + // conversation the slice came from. + let request = self.request( + session, + BackendAction::User(Action::Next), + session.context.clone(), + &NextState::GoalLoop, + ); + let backend = self.backend.clone(); + let handle = tokio::spawn(async move { backend.next_card(request).await }); + + self.continuations + .insert(session_id.to_string(), Continuation { handle }); + } + + /// Consumes the speculated next slice when the last queued hunk was + /// accepted. A finished speculation returns instantly; an in-flight one is + /// awaited — the only wait, bounded by one slice's generation. Failures + /// fall back to the real request path. + pub(super) async fn take_goal_continuation( + &mut self, + session_id: &str, + ) -> Option { + let entry = self.continuations.remove(session_id)?; + + match entry.handle.await { + Ok(Ok(response)) => Some(response), + _ => None, + } + } + + /// Aborts the speculation after a reject/retry/reply invalidated the slice + /// it continued from. A finished speculation folds its usage into the + /// session totals right away; an unfinished one keeps running detached and + /// is folded once it completes — wasted turns stay visible either way, + /// the same policy as the Fix prefetch. + pub(super) async fn cancel_goal_continuation(&mut self, session: &mut Session) { + let Some(entry) = self.continuations.remove(&session.id) else { + return; + }; + + if entry.handle.is_finished() { + if let Ok(Ok(response)) = entry.handle.await { + fold_usage(session, &response.metadata.token_usage); + } + return; + } + + self.cancelled_continuations + .push((session.id.clone(), entry.handle)); + } + + /// Folds cancelled speculations that have since finished into their + /// sessions' token totals; still-running ones stay queued for a later + /// sweep. + async fn reap_cancelled_continuations(&mut self) { + let pending = std::mem::take(&mut self.cancelled_continuations); + + for (session_id, handle) in pending { + if !handle.is_finished() { + self.cancelled_continuations.push((session_id, handle)); + continue; + } + if let Ok(Ok(response)) = handle.await + && let Some(session) = self.sessions.get_mut(&session_id) + { + fold_usage(session, &response.metadata.token_usage); + } + } + } } fn fold_usage(session: &mut Session, usage: &Option) { diff --git a/rust/crates/loopbiotic_harness/src/engine/tests.rs b/rust/crates/loopbiotic_harness/src/engine/tests.rs index 2fc6a55..d9766ec 100644 --- a/rust/crates/loopbiotic_harness/src/engine/tests.rs +++ b/rust/crates/loopbiotic_harness/src/engine/tests.rs @@ -172,6 +172,10 @@ async fn continuous_goal_reviews_a_multi_file_batch_without_more_model_turns() { assert!(!first_patch.goal_complete); assert_eq!(backend.calls.load(Ordering::SeqCst), 1); assert_eq!(reads.load(Ordering::SeqCst), 2); + assert!( + engine.continuations.is_empty(), + "a legacy full batch must not trigger slice speculation" + ); let second = engine .apply_result(PatchApplyResult { @@ -578,6 +582,321 @@ async fn action_uses_refreshed_editor_context() { assert_eq!(card.patches[0].file, PathBuf::from("templates/layout.html")); } +fn accept(session_id: &str, card: &Card, buffer: &str) -> PatchApplyResult { + let Card::Patch(card) = card else { + panic!("expected a pending patch card, got {card:?}"); + }; + let mut context = editor_context(buffer); + context.file = card.patches[0].file.clone(); + + PatchApplyResult { + session_id: session_id.into(), + card_id: card.id.clone(), + accepted: true, + patch_ids: vec![card.patches[0].id.clone()], + changed_files: vec![card.patches[0].file.clone()], + error: None, + context, + } +} + +fn sliced_goal_provider() -> SourceContextProvider { + Arc::new(|file, _session_id| { + Box::pin(async move { + let text = match file.to_str() { + Some("src/work.ts") => "placeholder", + Some("src/caller.ts") => "caller", + Some("src/shape.ts") => "shape", + _ => return None, + }; + let mut context = editor_context(text); + context.file = file; + Some(context) + }) + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn sliced_goal_speculates_and_consumes_each_next_slice_on_accept() { + let backend = Arc::new(CountingBackend::default()); + let mut engine = Engine::new(backend.clone()); + engine.set_source_context_provider(sliced_goal_provider()); + let mut goal = params(); + goal.mode = Mode::Auto; + + let first = engine.start(goal).await.unwrap(); + let Card::Patch(first_patch) = &first.card else { + panic!( + "expected the first slice, got {:?}; attempts {:?}", + first.card, first.attempts + ); + }; + assert_eq!(first_patch.patches[0].file, PathBuf::from("src/work.ts")); + assert!(!first_patch.goal_complete); + let plan = first_patch + .plan + .as_ref() + .expect("first slice carries a plan"); + assert!(!plan.complete); + assert_eq!(plan.remaining.len(), 2); + assert!( + engine.continuations.contains_key(&first.session_id), + "the next slice must be speculated while the first is reviewed" + ); + + let second = engine + .apply_result(accept( + &first.session_id, + &first.card, + "payload = payload or {}", + )) + .await + .unwrap(); + let Card::Patch(second_patch) = &second.card else { + panic!("expected the second slice, got {:?}", second.card); + }; + assert_eq!(second_patch.patches[0].file, PathBuf::from("src/caller.ts")); + assert!(!second_patch.goal_complete); + assert!( + second.turn_token_usage.total_tokens > 0, + "the consumed speculation's usage must stay visible" + ); + assert_eq!(second.goal.status, loopbiotic_protocol::GoalStatus::Active); + assert!( + engine.continuations.contains_key(&second.session_id), + "the final slice must be speculated while the second is reviewed" + ); + + let third = engine + .apply_result(accept(&second.session_id, &second.card, "CALLER")) + .await + .unwrap(); + let Card::Patch(third_patch) = &third.card else { + panic!("expected the final slice, got {:?}", third.card); + }; + assert_eq!(third_patch.patches[0].file, PathBuf::from("src/shape.ts")); + assert!(third_patch.goal_complete, "final slice completes the goal"); + assert!( + !engine.continuations.contains_key(&third.session_id), + "a complete plan leaves nothing to speculate" + ); + + let complete = engine + .apply_result(accept(&third.session_id, &third.card, "SHAPE")) + .await + .unwrap(); + assert!(matches!(complete.card, Card::Summary(_))); + assert_eq!( + complete.goal.status, + loopbiotic_protocol::GoalStatus::Complete + ); + assert_eq!(complete.goal.completed_steps.len(), 3); + assert_eq!(complete.turn_token_usage.total_tokens, 0); + + // One real turn plus two consumed speculations; no user action waited for + // a fresh model turn after the first card. + let calls = backend.calls.lock().unwrap().clone(); + assert_eq!(calls.len(), 3, "unexpected backend calls: {calls:?}"); + assert!(calls[0].starts_with("progress:Start")); + assert!(calls[1].starts_with("plain:User(Next)")); + assert!(calls[2].starts_with("plain:User(Next)")); +} + +/// Waits until the session's scheduled continuation turn has finished on the +/// backend. `schedule_goal_continuation` inserts the map entry synchronously, +/// so a pending speculation can never be missed here; only its background +/// completion is awaited. +async fn settle_continuation(engine: &Engine, session_id: &str) { + for _ in 0..2_000 { + if engine.continuations[session_id].is_finished() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + panic!("speculated continuation for {session_id} never finished"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn rejected_slice_cancels_speculation_folds_its_usage_and_respeculates() { + let backend = Arc::new(CountingBackend::default()); + let mut engine = Engine::new(backend.clone()); + engine.set_source_context_provider(sliced_goal_provider()); + let mut goal = params(); + goal.mode = Mode::Auto; + let start = engine.start(goal).await.unwrap(); + let Card::Patch(first_patch) = &start.card else { + panic!("expected the first slice, got {:?}", start.card); + }; + let usage_after_start = start.token_usage.total_tokens; + + // Let the speculation finish so cancelling it folds usage immediately. + settle_continuation(&engine, &start.session_id).await; + + let reworked = engine + .apply_result(PatchApplyResult { + session_id: start.session_id.clone(), + card_id: start.card.id().into(), + accepted: false, + patch_ids: vec![first_patch.patches[0].id.clone()], + changed_files: vec![], + error: Some("wrong shape".into()), + context: editor_context("placeholder"), + }) + .await + .unwrap(); + + let Card::Patch(rework) = &reworked.card else { + panic!("expected a reworked slice, got {:?}", reworked.card); + }; + assert_eq!(rework.patches[0].file, PathBuf::from("src/work.ts")); + assert!(rework.explanation.starts_with("Rework:")); + assert!( + reworked.token_usage.total_tokens + > usage_after_start + reworked.turn_token_usage.total_tokens, + "the cancelled speculation's usage must fold into the session totals" + ); + assert!( + engine.continuations.contains_key(&start.session_id), + "a validated rework must re-speculate its continuation" + ); + + // The rework keeps the plan, so accepting it consumes a speculated slice + // for the next planned file. + let second = engine + .apply_result(accept( + &reworked.session_id, + &reworked.card, + "PAYLOAD = PAYLOAD OR {}", + )) + .await + .unwrap(); + let Card::Patch(second_patch) = &second.card else { + panic!("expected the second slice, got {:?}", second.card); + }; + assert_eq!(second_patch.patches[0].file, PathBuf::from("src/caller.ts")); + + // The consumed re-speculation surfaced the second slice, whose plan still + // continues, so exactly one speculation for the third slice must now be + // in flight; settle it so the call log is complete and deterministic. + assert!( + engine.continuations.contains_key(&second.session_id), + "the second slice under review must speculate the third" + ); + settle_continuation(&engine, &second.session_id).await; + + let calls = backend.calls.lock().unwrap().clone(); + // Start, cancelled speculation, rework, consumed re-speculation, and the + // (exactly one) speculation for the slice now under review. Any engine + // double-schedule or a take() falling back to a real turn would add a + // call and fail this exact count. + assert_eq!(calls.len(), 5, "unexpected backend calls: {calls:?}"); + assert!(calls[0].starts_with("progress:Start")); + assert!(calls[1].starts_with("plain:User(Next)")); + assert!(calls[2].starts_with("progress:User(Retry)")); + assert!(calls[3].starts_with("plain:User(Next)")); + assert!(calls[4].starts_with("plain:User(Next)")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn user_retry_on_a_slice_cancels_speculation_and_stops_speculating() { + let backend = Arc::new(CountingBackend::default()); + let mut engine = Engine::new(backend.clone()); + engine.set_source_context_provider(sliced_goal_provider()); + let mut goal = params(); + goal.mode = Mode::Auto; + let first = engine.start(goal).await.unwrap(); + assert!(engine.continuations.contains_key(&first.session_id)); + + let redrafted = engine + .action(&first.session_id, Action::Retry) + .await + .unwrap(); + + assert!(matches!(redrafted.card, Card::Patch(_))); + assert!( + !engine.continuations.contains_key(&first.session_id), + "a redraft abandons the slice chain, so nothing may be speculated" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn single_file_goal_without_plan_completes_as_a_batch_of_one() { + let backend = Arc::new(SingleFileNoPlanBackend::default()); + let mut engine = Engine::new(backend.clone()); + let mut goal = params(); + goal.mode = Mode::Auto; + goal.buffer_text = "first".into(); + + let first = engine.start(goal).await.unwrap(); + let Card::Patch(card) = &first.card else { + panic!("expected the whole batch as one card, got {:?}", first.card); + }; + assert!( + card.goal_complete, + "legacy goal_complete governs completion" + ); + assert_eq!(card.plan, None); + assert!( + engine.continuations.is_empty(), + "a planless response must not trigger slice speculation" + ); + + let complete = engine + .apply_result(accept(&first.session_id, &first.card, "FIRST")) + .await + .unwrap(); + + assert!(matches!(complete.card, Card::Summary(_))); + assert_eq!( + complete.goal.status, + loopbiotic_protocol::GoalStatus::Complete + ); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); +} + +#[derive(Default)] +struct SingleFileNoPlanBackend { + calls: AtomicUsize, +} + +#[async_trait] +impl BackendAdapter for SingleFileNoPlanBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + assert!(req.card_contract.allow_goal_completion); + + Ok(BackendResponse { + card: Card::Patch(PatchCard { + id: "c_single".into(), + title: "Complete local change".into(), + explanation: "One edit finishes the goal.".into(), + warnings: vec![], + goal_complete: true, + plan: None, + patches: vec![FilePatch { + id: "p_single".into(), + file: "src/work.ts".into(), + diff: "@@ -1,1 +1,1 @@\n-first\n+FIRST\n".into(), + explanation: "Updates the only required location.".into(), + }], + actions: vec![Action::Apply, Action::Why, Action::Retry, Action::Stop], + }), + raw_output: None, + metadata: BackendMetadata { + backend: "single_file_no_plan".into(), + model: None, + token_usage: Some(TokenUsage::estimated(100, 20)), + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + MockBackend::info() + } +} + #[derive(Default)] struct FlakyBackend { failed: AtomicBool, @@ -607,6 +926,7 @@ impl BackendAdapter for BatchGoalBackend { explanation: "Prepare both independent edits.".into(), warnings: vec![], goal_complete: true, + plan: None, patches: vec![FilePatch { id: "p_batch".into(), file: "src/work.ts".into(), @@ -649,6 +969,7 @@ impl BackendAdapter for MultiFileGoalBackend { explanation: "Update both required files.".into(), warnings: vec![], goal_complete: true, + plan: None, patches: vec![ FilePatch { id: "p_work".into(), @@ -846,6 +1167,7 @@ impl BackendAdapter for BadPatchBackend { explanation: "Invalid patch.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: "src/work.ts".into(), @@ -900,6 +1222,7 @@ impl BackendAdapter for RepairingPatchBackend { explanation: "This attempt has stale context.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: "src/work.ts".into(), @@ -917,6 +1240,7 @@ impl BackendAdapter for RepairingPatchBackend { explanation: "Use exact current context.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: "src/work.ts".into(), @@ -1208,6 +1532,7 @@ impl BackendAdapter for NavigatingBackend { explanation: "Sets the icon size on the component.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: req.context.file.clone(), diff --git a/rust/crates/loopbiotic_harness/src/engine/turn.rs b/rust/crates/loopbiotic_harness/src/engine/turn.rs index 1d1938b..9c46e09 100644 --- a/rust/crates/loopbiotic_harness/src/engine/turn.rs +++ b/rust/crates/loopbiotic_harness/src/engine/turn.rs @@ -234,7 +234,7 @@ impl Engine { }); } let instruction = if matches!(expected, NextState::GoalLoop) { - "Re-read every affected file with read-only tools and return the corrected complete multi-file batch. Context/remove lines must be exact and contiguous in each corresponding source. Do not split the goal into another model turn; use open_location only if a required source cannot be inspected." + "Re-read every affected file with read-only tools and return the corrected patch with the same scope as before (the same file slice plus its plan, or the same complete batch). Context/remove lines must be exact and contiguous in each corresponding source. Use open_location only if a required source cannot be inspected." } else { "Rebuild the same step. Source context/remove lines must be exact and contiguous in the supplied buffer; added lines do not replace omitted source context. The resulting local step must remain type-correct without work deferred to a later card. If the change belongs in a different file than the supplied buffer, return an open_location op with that place instead of another patch." }; @@ -332,6 +332,7 @@ impl Engine { explanation: card.explanation.clone(), warnings: vec![], goal_complete: card.goal_complete, + plan: None, patches: vec![card.patches[index].clone()], actions: card.actions.clone(), }); @@ -510,6 +511,7 @@ mod tests { explanation: "Keep validation local.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_repeat".into(), file: PathBuf::from("src/work.ts"), diff --git a/rust/crates/loopbiotic_harness/src/session.rs b/rust/crates/loopbiotic_harness/src/session.rs index 77bdc13..fcfeba1 100644 --- a/rust/crates/loopbiotic_harness/src/session.rs +++ b/rust/crates/loopbiotic_harness/src/session.rs @@ -31,6 +31,10 @@ pub struct Session { pub completed_steps: Vec, pub completed_step_signatures: Vec<(PathBuf, String)>, pub pending_patch_cards: VecDeque, + /// True while the slice under review came from a sliced goal response + /// whose plan says more file slices follow; the engine speculatively + /// requests the next slice and consumes it once the queue drains. + pub goal_slice_continues: bool, pub goal_status: GoalStatus, pub next_step: Option, pub continuous_goal: bool, @@ -67,6 +71,7 @@ impl Session { completed_steps: vec![], completed_step_signatures: vec![], pending_patch_cards: VecDeque::new(), + goal_slice_continues: false, goal_status: GoalStatus::Active, next_step: None, continuous_goal, diff --git a/rust/crates/loopbiotic_harness/src/state.rs b/rust/crates/loopbiotic_harness/src/state.rs index b7b3c02..20361f1 100644 --- a/rust/crates/loopbiotic_harness/src/state.rs +++ b/rust/crates/loopbiotic_harness/src/state.rs @@ -261,6 +261,7 @@ mod tests { explanation: "e".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![], actions: vec![Action::Apply], }) diff --git a/rust/crates/loopbiotic_patch/src/validate.rs b/rust/crates/loopbiotic_patch/src/validate.rs index 77872d2..aaae205 100644 --- a/rust/crates/loopbiotic_patch/src/validate.rs +++ b/rust/crates/loopbiotic_patch/src/validate.rs @@ -474,6 +474,7 @@ mod tests { explanation: "Indent the block.".into(), warnings: vec![], goal_complete: true, + plan: None, patches: vec![FilePatch { id: "p_format".into(), file: PathBuf::from("templates/view.html"), @@ -527,6 +528,7 @@ mod tests { explanation: "Add the missing exception type.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_new".into(), file: PathBuf::from("src/Exception/NewException.php"), @@ -560,6 +562,7 @@ mod tests { explanation: "Insert without an anchor.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_insert".into(), file: PathBuf::from("src/work.ts"), @@ -593,6 +596,7 @@ mod tests { explanation: "Rename the value.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: PathBuf::from("src/work.ts"), @@ -628,6 +632,7 @@ mod tests { explanation: "Rename the value.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: PathBuf::from("src/work.ts"), @@ -662,6 +667,7 @@ mod tests { explanation: "Rename the value.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: PathBuf::from("src/work.ts"), @@ -718,6 +724,7 @@ mod tests { explanation: "Fix.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![raw], actions: vec![loopbiotic_protocol::Action::Apply], }); @@ -742,6 +749,7 @@ mod tests { explanation: "Fix the guard.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: PathBuf::from("src/work.ts"), @@ -784,6 +792,7 @@ mod tests { explanation: "Rename the value.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: PathBuf::from("src/work.ts"), @@ -818,6 +827,7 @@ mod tests { explanation: "Rename response to rpc_response.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: PathBuf::from("src/work.rs"), @@ -846,6 +856,7 @@ mod tests { explanation: "Rename response to rpc_response.".into(), warnings: vec![], goal_complete: false, + plan: None, patches: vec![FilePatch { id: "p_1".into(), file: PathBuf::from("src/work.rs"), diff --git a/rust/crates/loopbiotic_protocol/src/agent.rs b/rust/crates/loopbiotic_protocol/src/agent.rs index 6c5882a..ed763ca 100644 --- a/rust/crates/loopbiotic_protocol/src/agent.rs +++ b/rust/crates/loopbiotic_protocol/src/agent.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::{ - Action, Card, ChoiceCard, ChoiceOption, DenyCard, ErrorCard, FilePatch, FindingCard, + Action, Card, ChoiceCard, ChoiceOption, DenyCard, ErrorCard, FilePatch, FindingCard, GoalPlan, HypothesisCard, Location, LocationEvidence, NextMove, OpenLocationCard, PatchCard, SummaryCard, }; @@ -27,6 +27,8 @@ pub enum AgentOp { explanation: String, #[serde(default)] goal_complete: Option, + #[serde(default)] + plan: Option, patches: Vec, }, Choice { @@ -233,6 +235,7 @@ impl AgentOp { title, explanation, goal_complete, + plan, patches, } => Card::Patch(PatchCard { id, @@ -240,6 +243,7 @@ impl AgentOp { explanation, warnings: vec![], goal_complete: goal_complete.unwrap_or(false), + plan, patches: patches .into_iter() .enumerate() @@ -521,6 +525,35 @@ mod tests { ); } + #[test] + fn maps_patch_op_plan_onto_the_card() { + let op: AgentOp = serde_json::from_str( + r#"{"op":"patch","title":"Slice A","explanation":"First file.","goal_complete":false,"plan":{"remaining":[{"file":"src/caller.ts","summary":"Update the consumer."}],"complete":false},"patches":[{"file":"src/work.ts","diff":"@@ -1,1 +1,1 @@\n-a\n+b\n","explanation":"E"}]}"#, + ) + .unwrap(); + let card = op.into_card("c_1"); + + let Card::Patch(card) = card else { + panic!("expected patch card"); + }; + let plan = card.plan.expect("plan must survive the op mapping"); + assert!(!plan.complete); + assert_eq!(plan.remaining[0].file, "src/caller.ts"); + } + + #[test] + fn parses_patch_op_without_plan() { + let op: AgentOp = serde_json::from_str( + r#"{"op":"patch","title":"T","explanation":"E","patches":[{"file":"src/work.ts","diff":"@@ -1,1 +1,1 @@\n-a\n+b\n","explanation":"E"}]}"#, + ) + .unwrap(); + + let Card::Patch(card) = op.into_card("c_1") else { + panic!("expected patch card"); + }; + assert_eq!(card.plan, None); + } + #[test] fn maps_op_to_card() { let op = AgentOp::Hypothesis { diff --git a/rust/crates/loopbiotic_protocol/src/card.rs b/rust/crates/loopbiotic_protocol/src/card.rs index 8867935..d4c0d92 100644 --- a/rust/crates/loopbiotic_protocol/src/card.rs +++ b/rust/crates/loopbiotic_protocol/src/card.rs @@ -134,10 +134,30 @@ pub struct PatchCard { /// queued card retains this flag. #[serde(default, skip_serializing_if = "is_false")] pub goal_complete: bool, + /// Sliced goal turns return exactly one file's patch plus this plan of + /// what remains. Absent on legacy full-batch responses. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan: Option, pub patches: Vec, pub actions: Vec, } +/// The remainder of a sliced goal: which files still need their own slice and +/// whether the current slice is the final one. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct GoalPlan { + #[serde(default)] + pub remaining: Vec, + /// True when the goal has no further slices after the current one. + pub complete: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PlannedStep { + pub file: String, + pub summary: String, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ChoiceCard { pub id: CardId, @@ -233,4 +253,69 @@ mod tests { assert_eq!(json["kind"], "hypothesis"); assert_eq!(json["actions"][0], "follow"); } + + fn patch_card(plan: Option) -> Card { + Card::Patch(PatchCard { + id: "c_p".into(), + title: "Slice".into(), + explanation: "One file.".into(), + warnings: vec![], + goal_complete: false, + plan, + patches: vec![], + actions: vec![Action::Apply], + }) + } + + #[test] + fn serializes_goal_plan_on_sliced_patch_cards() { + let card = patch_card(Some(GoalPlan { + remaining: vec![PlannedStep { + file: "src/caller.ts".into(), + summary: "Update the consumer.".into(), + }], + complete: false, + })); + + let json = serde_json::to_value(card.clone()).unwrap(); + + assert_eq!(json["plan"]["complete"], false); + assert_eq!(json["plan"]["remaining"][0]["file"], "src/caller.ts"); + assert_eq!( + json["plan"]["remaining"][0]["summary"], + "Update the consumer." + ); + let round_trip: Card = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip, card); + } + + #[test] + fn omits_absent_plan_and_accepts_planless_patch_cards() { + let json = serde_json::to_value(patch_card(None)).unwrap(); + assert!(json.get("plan").is_none()); + + // Cards serialized before the plan field existed must still parse. + let legacy = serde_json::json!({ + "kind": "patch", + "id": "c_p", + "title": "Slice", + "explanation": "One file.", + "patches": [], + "actions": ["apply"], + }); + let card: Card = serde_json::from_value(legacy).unwrap(); + let Card::Patch(card) = card else { + panic!("expected patch card"); + }; + assert_eq!(card.plan, None); + assert!(!card.goal_complete); + } + + #[test] + fn goal_plan_remaining_defaults_to_empty() { + let plan: GoalPlan = serde_json::from_value(serde_json::json!({"complete": true})).unwrap(); + + assert!(plan.complete); + assert!(plan.remaining.is_empty()); + } } diff --git a/schemas/loopbiotic-agent-op.schema.json b/schemas/loopbiotic-agent-op.schema.json index c0ca9b6..d11f4bf 100644 --- a/schemas/loopbiotic-agent-op.schema.json +++ b/schemas/loopbiotic-agent-op.schema.json @@ -76,6 +76,26 @@ "goal_complete": { "type": ["boolean", "null"] }, + "plan": { + "type": ["object", "null"], + "required": ["remaining", "complete"], + "properties": { + "remaining": { + "type": "array", + "items": { + "type": "object", + "required": ["file", "summary"], + "properties": { + "file": { "type": "string" }, + "summary": { "type": "string" } + }, + "additionalProperties": false + } + }, + "complete": { "type": "boolean" } + }, + "additionalProperties": false + }, "patches": { "type": ["array", "null"], "items": { From 3ecb50affbd57c11512614b87a72ea4fd05833b2 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 16:44:57 +0200 Subject: [PATCH 28/37] docs: describe the sliced goal flow --- CHANGELOG.md | 12 ++++++++++++ README.md | 22 ++++++++++++++-------- doc/loopbiotic.txt | 14 +++++++++----- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d1b9e..dcf1fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,18 @@ The project follows [Semantic Versioning](https://semver.org/). ### Added +- Goal turns are now sliced: the agent returns exactly one file's patch per + turn plus a plan of the remaining files, and Loopbiotic speculatively + requests the next slice on the same conversation while the current one is + under review. Time-to-first-hunk drops from the full-batch generation time + to a single slice, accepting usually surfaces the next file instantly, and + a rejected slice reworks one file instead of regenerating the whole batch + (the cancelled speculation's token cost stays visible in session totals). + Backends that ignore the slice contract keep the legacy complete-batch + behavior. New Lua-side additions: backend preflight in the prompt window + (failures surface before typing; composed prompts survive failed starts), + repeated-error escalation with actionable guidance, and a client-side + error boundary that preserves the session when a UI callback fails. - The `backend/warmup` handshake now reports an explicit identity: the active backend, the concrete model the next turn will use (configured, or resolved from the backend — the Claude CLI announces it at process start, Ollama diff --git a/README.md b/README.md index a278c7f..5737c58 100644 --- a/README.md +++ b/README.md @@ -196,8 +196,8 @@ require("loopbiotic").setup({ a Prompt Persistent agent goal -Agent inspects the project and prepares the complete multi-file change -One local editable hunk +Agent inspects the project and returns the first file slice plus its plan +One local editable hunk (the next slice is prepared while you review) Edit the inline draft Accept, Reject, edit, message, or ask Why Why → explanation → Back to the same pending draft @@ -239,12 +239,18 @@ Unknown words after `/` are treated as normal prompt text, so paths like `/tmp/project` are safe. The goal and accepted-step count stay visible on cards and editable drafts. In -the default `auto` mode the agent owns the complete process: it inspects the -project and prepares the complete change across the required files in one turn. -Loopbiotic then presents that batch hunk by hunk; `Accept` advances locally without -another model call, and accepting the final complete hunk closes the goal -locally. `Reject`, `Retry`, a file the agent could not inspect, or an explicit -question can return control to the agent. User control is the hunk gate, not a +the default `auto` mode the agent owns the complete process, delivered as file +slices: each goal turn returns exactly one file's complete patch plus a plan +of the remaining files, so no single wait spans the whole change. While a +slice is under review, Loopbiotic already requests the next one on the same +conversation — accepting the last hunk of a slice usually surfaces the next +file instantly. `Accept` advances locally without another model call; +accepting the final hunk of the last planned slice closes the goal locally. +`Reject` cancels the speculative next slice (its token cost stays visible), +reworks only the rejected file, and resumes the pipeline. Backends that +ignore the slice contract still return one complete batch, which is presented +exactly as before. `Retry`, a file the agent could not inspect, or an +explicit question can return control to the agent. User control is the hunk gate, not a repeated discovery/assess/draft ceremony. Asking `Why` opens a side conversation about the pending hunk and then returns to that exact draft without advancing or replacing it. Explicit diff --git a/doc/loopbiotic.txt b/doc/loopbiotic.txt index 0a00e6b..cc3fec4 100644 --- a/doc/loopbiotic.txt +++ b/doc/loopbiotic.txt @@ -17,11 +17,15 @@ designed for explicit user-controlled pair-programming steps rather than an open-ended chat session. In the default auto mode, one prompt starts a persistent goal loop. The agent -inspects the project and returns the complete multi-file change as one batch. -Loopbiotic validates that batch against live editor buffers and presents it one -hunk at a time. Accepting a hunk advances through the local queue without a new -model turn; rejecting or retrying returns control to the agent. The loop ends -only after the complete batch is accepted and local diagnostics are checked. +returns the change one file slice at a time, together with a plan of the +remaining files; while a slice is under review Loopbiotic already requests the +next one on the same conversation, so accepting usually surfaces the next +file without a wait. Every slice is validated against live editor buffers and +presented one hunk at a time. Accepting a hunk advances through the local +queue without a new model turn; rejecting reworks only that file's slice and +cancels the speculative next one. Backends that ignore the slice contract +return one complete batch, presented exactly as before. The loop ends only +after the final planned slice is accepted and local diagnostics are checked. Use `keymaps.why` (`pw` by default) on a draft to ask why; the answer keeps that exact hunk pending and offers `Back to draft`. From 99af227422152ee244a4ff384ec95461e3e63e09 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 16:52:08 +0200 Subject: [PATCH 29/37] feat: one-keypress slice retry when a queued draft no longer applies --- lua/loopbiotic/diff.lua | 77 +++++++++++++++++-- tests/lua/test_drift.lua | 159 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 tests/lua/test_drift.lua diff --git a/lua/loopbiotic/diff.lua b/lua/loopbiotic/diff.lua index b4c2242..69be89a 100644 --- a/lua/loopbiotic/diff.lua +++ b/lua/loopbiotic/diff.lua @@ -38,21 +38,23 @@ function M.show(card, opts) return false end + -- Queued patches were validated daemon-side, so a failure here means the + -- draft went stale in review: parse failures are a malformed diff, while + -- resolve/apply failures mean the buffer drifted after the draft was made + -- (cards carry no queue-time changedtick, so resolution failure itself is + -- the drift signal). Offer recovery instead of dead-ending the goal. local source_lines = vim.api.nvim_buf_get_lines(source_buf, 0, -1, false) local hunk_ok, hunk = pcall(apply.parse_hunk, patch.diff) if not hunk_ok then - ui.notify(hunk, vim.log.levels.ERROR) - return false + return M.recover(card, "malformed", hunk) end local start_ok, source_start = pcall(apply.resolve_start, source_lines, hunk) if not start_ok then - ui.notify(source_start, vim.log.levels.ERROR) - return false + return M.recover(card, "drift", source_start) end local draft_ok, draft_lines = pcall(apply.apply_diff, source_lines, patch.diff) if not draft_ok then - ui.notify(draft_lines, vim.log.levels.ERROR) - return false + return M.recover(card, "drift", draft_lines) end local annotations = M.annotations(hunk, source_start) @@ -105,6 +107,69 @@ function M.show(card, opts) return true end +-- Decide what recovery to offer for a queued patch that failed to preview. +-- Pure (kinds and actions in, choices out) so headless tests can cover the +-- decision table directly. +-- +-- Retrying redrafts the current goal slice against the live buffer, which is +-- cheap, so it comes first: recovery is one keypress. There is no "skip this +-- hunk" choice because no skip path exists — patch cards carry a single hunk +-- and reject reworks the card rather than advancing past it. +---@param kind "malformed"|"drift" parse failure vs. stale buffer context +---@param actions (string|table)[]|nil the card's available actions +---@return { reason: string, choices: { label: string, action: "retry"|"cancel" }[] }|nil plan nil when the card cannot retry +function M.recovery_plan(kind, actions) + local can_retry = false + for _, action in ipairs(actions or {}) do + if action == "retry" then + can_retry = true + end + end + if not can_retry then + return nil + end + + return { + reason = kind == "malformed" and "the drafted patch is malformed" + or "draft no longer matches the buffer (edited since it was drafted)", + choices = { + { label = "Retry slice with current buffer", action = "retry" }, + { label = "Cancel", action = "cancel" }, + }, + } +end + +-- A queued patch failed to preview: prompt for recovery instead of leaving +-- the goal at a dead end. The retry turn costs tokens, so it only fires on +-- the user's explicit pick — never automatically. Always returns false so +-- callers fall back to the plain card while the choice is pending. +---@param card LoopbioticCard +---@param kind "malformed"|"drift" +---@param err string the underlying parse/resolve/apply error +---@return boolean shown always false +function M.recover(card, kind, err) + log.write("patch preview failed", { kind = kind, error = err }) + + local plan = M.recovery_plan(kind, card.actions or card.next_actions) + if not plan then + ui.notify(err, vim.log.levels.ERROR) + return false + end + + vim.ui.select(plan.choices, { + prompt = "Loopbiotic: " .. plan.reason, + format_item = function(choice) + return choice.label + end, + }, function(choice) + if choice and choice.action == "retry" then + require("loopbiotic").action("retry", { allow_hidden = true }) + end + end) + + return false +end + function M.annotations(hunk, source_start) local row = source_start local annotations = { diff --git a/tests/lua/test_drift.lua b/tests/lua/test_drift.lua new file mode 100644 index 0000000..bbb22ba --- /dev/null +++ b/tests/lua/test_drift.lua @@ -0,0 +1,159 @@ +-- Recovery when a queued patch no longer applies: the daemon validated the +-- patch at queue time, but the user can edit the buffer during review. A +-- stale draft must offer a way back into the goal loop (retry redrafts the +-- current slice, which is cheap), not dead-end on a raw error notification. +local diff = require("loopbiotic.diff") + +return function(t) + local retry_label = "Retry slice with current buffer" + + -- Run fn with vim.ui.select and vim.notify captured; always restores both. + local function with_recovery_stubs(fn) + local original_select = vim.ui.select + local original_notify = vim.notify + local captured = { selects = {}, notifications = {} } + vim.ui.select = function(items, opts, on_choice) + table.insert(captured.selects, { items = items, opts = opts, on_choice = on_choice }) + end + vim.notify = function(message, level) + table.insert(captured.notifications, { message = message, level = level }) + end + local ok, err = pcall(fn, captured) + vim.ui.select = original_select + vim.notify = original_notify + if not ok then + error(err, 0) + end + end + + -- Open a scratch file whose buffer content has drifted to `lines` after + -- the card was drafted, and return a queued patch card targeting it. + local function drifted_card(lines, diff_text, actions) + local file = vim.fn.tempname() .. ".txt" + vim.fn.writefile(lines, file) + vim.cmd("edit " .. vim.fn.fnameescape(file)) + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + + return { + id = "card-1", + kind = "patch", + actions = actions, + patches = { { id = "patch-1", file = file, diff = diff_text } }, + }, + file + end + + local function cleanup(file) + vim.cmd("bwipeout!") + vim.fn.delete(file) + end + + t.test("recovery_plan offers retry-first recovery for drift", function() + local plan = diff.recovery_plan("drift", { "retry", "edit_prompt", "stop" }) + t.eq(plan.reason, "draft no longer matches the buffer (edited since it was drafted)") + t.eq(#plan.choices, 2, "choice count") + t.eq(plan.choices[1], { label = retry_label, action = "retry" }, "retry is first, the default") + t.eq(plan.choices[2], { label = "Cancel", action = "cancel" }) + end) + + t.test("recovery_plan explains malformed drafts differently", function() + local plan = diff.recovery_plan("malformed", { "retry" }) + t.eq(plan.reason, "the drafted patch is malformed") + t.eq(plan.choices[1].action, "retry", "retry is the only sensible offer") + t.eq(plan.choices[2].action, "cancel") + end) + + t.test("recovery_plan finds retry among mixed action entries", function() + local plan = diff.recovery_plan("drift", { { apply_patch = { id = "p1" } }, "retry" }) + t.eq(plan ~= nil, true, "table entries do not hide the retry action") + end) + + t.test("recovery_plan offers nothing when the card cannot retry", function() + t.eq(diff.recovery_plan("drift", { "edit_prompt", "stop" }), nil, "no retry action") + t.eq(diff.recovery_plan("malformed", {}), nil, "empty actions") + t.eq(diff.recovery_plan("drift", nil), nil, "missing actions") + end) + + t.test("show prompts for recovery when the buffer drifted", function() + local card, file = drifted_card({ "edited since the draft" }, "@@ -1,1 +1,1 @@\n-original\n+patched\n", { "retry" }) + + with_recovery_stubs(function(captured) + t.eq(diff.show(card), false, "not shown") + + t.eq(#captured.selects, 1, "recovery selector invoked") + local select = captured.selects[1] + t.eq(#select.items, 2, "two choices") + t.eq(select.opts.format_item(select.items[1]), retry_label, "retry is the first choice") + t.eq( + select.opts.prompt:find("no longer matches the buffer", 1, true) ~= nil, + true, + "prompt names the drift reason" + ) + + local errors = vim.tbl_filter(function(entry) + return entry.level == vim.log.levels.ERROR + end, captured.notifications) + t.eq(errors, {}, "no raw error notification") + end) + + cleanup(file) + end) + + t.test("choosing retry fires the retry action; cancel does not", function() + local card, file = drifted_card({ "edited since the draft" }, "@@ -1,1 +1,1 @@\n-original\n+patched\n", { "retry" }) + + with_recovery_stubs(function(captured) + local loopbiotic = require("loopbiotic") + local original_action = loopbiotic.action + local actions = {} + loopbiotic.action = function(name, opts) + table.insert(actions, { name = name, opts = opts }) + end + + local ok, err = pcall(function() + diff.show(card) + local select = captured.selects[1] + + select.on_choice(nil) -- dismissed + select.on_choice(select.items[2]) -- explicit cancel + t.eq(actions, {}, "no agent turn without the user's pick") + + select.on_choice(select.items[1]) + t.eq(actions, { { name = "retry", opts = { allow_hidden = true } } }, "retry fired once") + end) + + loopbiotic.action = original_action + if not ok then + error(err, 0) + end + end) + + cleanup(file) + end) + + t.test("show prompts for recovery on a malformed queued patch", function() + local card, file = drifted_card({ "anything" }, "@@ -1,2 +1,3 @@\n+added only\n", { "retry" }) + + with_recovery_stubs(function(captured) + t.eq(diff.show(card), false, "not shown") + t.eq(#captured.selects, 1, "recovery selector invoked") + t.eq(captured.selects[1].opts.prompt:find("malformed", 1, true) ~= nil, true, "prompt names the parse failure") + end) + + cleanup(file) + end) + + t.test("show falls back to a plain error when the card cannot retry", function() + local card, file = drifted_card({ "edited since the draft" }, "@@ -1,1 +1,1 @@\n-original\n+patched\n", { "stop" }) + + with_recovery_stubs(function(captured) + t.eq(diff.show(card), false, "not shown") + t.eq(#captured.selects, 0, "no selector without a retry action") + t.eq(#captured.notifications, 1, "raw error notified") + t.eq(captured.notifications[1].level, vim.log.levels.ERROR, "error level") + t.eq(captured.notifications[1].message:find("not found", 1, true) ~= nil, true, "resolution failure surfaced") + end) + + cleanup(file) + end) +end From 7745ef675079b61bb30fb5fcc9ee0e49cab1fc34 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 18:19:37 +0200 Subject: [PATCH 30/37] refactor: stabilize backend prompt cache prefixes --- .../loopbiotic_backends/src/claude_app.rs | 192 ++++++++++++++---- .../loopbiotic_backends/src/codex_app/mod.rs | 77 ++++++- .../crates/loopbiotic_backends/src/generic.rs | 125 ++++++++++-- rust/crates/loopbiotic_backends/src/lib.rs | 10 + .../crates/loopbiotic_backends/src/support.rs | 37 ++++ 5 files changed, 380 insertions(+), 61 deletions(-) diff --git a/rust/crates/loopbiotic_backends/src/claude_app.rs b/rust/crates/loopbiotic_backends/src/claude_app.rs index 8aeaa77..c4b567c 100644 --- a/rust/crates/loopbiotic_backends/src/claude_app.rs +++ b/rust/crates/loopbiotic_backends/src/claude_app.rs @@ -757,46 +757,75 @@ impl BackendAdapter for ClaudeAppBackend { fn turn_prompt(req: &BackendRequest, include_context: bool) -> String { let goal_turn = req.card_contract.allow_goal_completion && req.card_contract.expected_kind != Some(loopbiotic_protocol::CardKind::Finding); - let mut value = json!({ - "s": { - "id": req.session.id, - "p": req.session.prompt, - "completed_steps": req.session.completed_steps, - "known_observations": req.session.known_observations, - "mode": req.session.mode, - "n": req.session.card_count, - "last": req.session.last_summary - }, - "a": action_value(&req.action), - "limits": { - "one": req.card_contract.one_card_only, - "max": req.card_contract.max_body_chars, - "patch_files": req.card_contract.max_patch_files, - "hunks_per_patch": req.card_contract.max_hunks_per_patch, - "changed_lines": req.card_contract.max_changed_lines, - "goal_completion": req.card_contract.allow_goal_completion, - "expected": req.card_contract.expected_kind - } - }); + + // Provider prompt caches key on byte-stable prefixes, so fields serialize + // stable-first: the session-stable block, then blocks that only change + // when the turn kind changes, then the truly per-turn data. The static + // contract itself lives in SYSTEM_PROMPT, which the conversation carries + // once per process. `ordered_json_object` preserves exactly this order; + // `json!` alone would sort keys alphabetically and put volatile bytes + // first. + let mut fields: Vec<(&str, Value)> = vec![ + // Session-stable: identical bytes on every turn of one session. + ( + "s", + json!({ + "id": req.session.id, + "mode": req.session.mode, + "p": req.session.prompt, + }), + ), + // Turn-kind-stable: constant across all turns of the same kind + // (expected op and the goal/non-goal limit set). + ( + "limits", + json!({ + "one": req.card_contract.one_card_only, + "max": req.card_contract.max_body_chars, + "patch_files": req.card_contract.max_patch_files, + "hunks_per_patch": req.card_contract.max_hunks_per_patch, + "changed_lines": req.card_contract.max_changed_lines, + "goal_completion": req.card_contract.allow_goal_completion, + "expected": req.card_contract.expected_kind, + }), + ), + ]; if goal_turn { - value["slice"] = json!(if matches!( - req.action, - crate::BackendAction::User(loopbiotic_protocol::Action::Next) - ) { - "Continue with the next planned file slice: one file's complete patch plus the refreshed plan." - } else { - "Return exactly one file's complete patch (the most foundational unresolved file first) plus plan {remaining:[{file,summary}],complete}." - }); + fields.push(( + "slice", + json!( + if matches!( + req.action, + crate::BackendAction::User(loopbiotic_protocol::Action::Next) + ) { + "Continue with the next planned file slice: one file's complete patch plus the refreshed plan." + } else { + "Return exactly one file's complete patch (the most foundational unresolved file first) plus plan {remaining:[{file,summary}],complete}." + } + ), + )); } - if include_context { - value["ctx"] = crate::backend_context(&req.context); - } else { - value["ctx"] = json!("unchanged; reuse the context from the previous message"); - } + // Append-only within a session: the serialized prefix of these lists + // stays byte-stable as they grow. + fields.push(("completed_steps", json!(req.session.completed_steps))); + fields.push(("known_observations", json!(req.session.known_observations))); + + // Volatile: changes every turn, so it must trail everything cacheable. + fields.push(("a", action_value(&req.action))); + fields.push(("last", json!(req.session.last_summary))); + fields.push(("n", json!(req.session.card_count))); + fields.push(( + "ctx", + if include_context { + crate::backend_context(&req.context) + } else { + json!("unchanged; reuse the context from the previous message") + }, + )); - serde_json::to_string(&value).unwrap_or_default() + crate::support::ordered_json_object(&fields) } fn parse_stream_event(value: &Value) -> StreamEvent { @@ -1220,4 +1249,97 @@ mod tests { assert!(!without_context.contains("buffer_text")); assert!(without_context.contains("unchanged")); } + + #[test] + fn turn_prompt_keeps_a_stable_prefix_across_turns_of_one_session() { + let turn_a = crate::test_request(); + let mut turn_b = crate::test_request(); + turn_b.action = crate::BackendAction::User(loopbiotic_protocol::Action::Follow); + turn_b + .session + .completed_steps + .push("renamed the helper".into()); + turn_b + .session + .known_observations + .push("the guard drops zero".into()); + turn_b.session.card_count = 3; + turn_b.session.last_summary = Some("Renamed the helper".into()); + turn_b.context.buffer_text = "fn main() { changed() }".into(); + turn_b.context.cursor.line = 9; + + let a = turn_prompt(&turn_a, true); + let b = turn_prompt(&turn_b, true); + + // Everything before the append-only lists — the session-stable `s` + // block and the turn-kind-stable `limits` block — must stay + // byte-identical so a provider prompt cache can reuse it. Volatile + // fields (action, counts, context) may only trail that prefix. + let stable_block_len = a.find("\"completed_steps\"").expect("lists present"); + assert_eq!(Some(stable_block_len), b.find("\"completed_steps\"")); + let shared = crate::common_prefix_len(&a, &b); + assert!( + shared >= stable_block_len, + "volatile bytes leaked into the stable prefix: shared {shared} < stable {stable_block_len}\nA: {a}\nB: {b}" + ); + } + + #[test] + fn goal_turn_prompt_keeps_a_stable_prefix_across_slices() { + let mut turn_a = crate::test_request(); + turn_a.card_contract.allow_goal_completion = true; + turn_a.action = crate::BackendAction::User(loopbiotic_protocol::Action::Next); + let mut turn_b = turn_a.clone(); + turn_b.session.completed_steps.push("patched lib.rs".into()); + turn_b.session.card_count = 2; + turn_b.context.buffer_text = "pub fn changed() {}".into(); + + let a = turn_prompt(&turn_a, true); + let b = turn_prompt(&turn_b, true); + + // Consecutive goal continuations share `s`, `limits`, and the slice + // instruction; only the accepted-step history and context change. + let stable_block_len = a.find("\"completed_steps\"").expect("lists present"); + assert!(a[..stable_block_len].contains("\"slice\"")); + assert!(crate::common_prefix_len(&a, &b) >= stable_block_len); + } + + #[test] + fn turn_prompt_static_lead_is_stable_across_sessions() { + let session_a = crate::test_request(); + let mut session_b = crate::test_request(); + session_b.session.id = "s_2".into(); + session_b.session.prompt = "add retry logic to the fetcher".into(); + + let a = turn_prompt(&session_a, true); + let b = turn_prompt(&session_b, true); + + // Cross-session cache reuse comes from SYSTEM_PROMPT, delivered once + // per process via --append-system-prompt; the turn prompt itself only + // guarantees its structural lead-in before the session id. + let static_lead = "{\"s\":{\"id\":\""; + assert!(a.starts_with(static_lead), "unexpected lead: {a}"); + assert!(crate::common_prefix_len(&a, &b) >= static_lead.len()); + } + + #[test] + fn spawn_args_carry_the_static_contract_and_no_session_data() { + let backend = ClaudeAppBackend::with_turn_timeout( + "claude-unused", + vec![], + Some("claude-fable-5".into()), + None, + None, + None, + ); + + let first = backend.spawn_args(&Some("claude-fable-5".into())); + let second = backend.spawn_args(&Some("claude-fable-5".into())); + + // The spawn arguments define the process-level static block (system + // prompt included); they must be identical for every spawn of the + // same configuration so the provider cache keys stay byte-stable. + assert_eq!(first, second); + assert!(first.contains(&SYSTEM_PROMPT.to_string())); + } } diff --git a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs index 9e490a1..112185b 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs @@ -405,6 +405,12 @@ impl BackendAdapter for CodexAppBackend { } } +/// Opens every turn prompt. A `const` so it can never interpolate volatile +/// data: it anchors the byte-stable prefix the provider prompt cache keys on. +const PROMPT_STATIC_HEADER: &str = "Return exactly one JSON Loopbiotic op. No markdown. No prose. +Patch file paths must be relative. +"; + fn prompt(req: &BackendRequest, include_context: bool) -> String { let patch_turn = turn_phase(req) == Phase::Patch; let goal_loop = req.card_contract.allow_goal_completion; @@ -512,21 +518,24 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { "Source context is unchanged from the preceding turn in this Loopbiotic thread. Reuse that exact buffer and ranked project context.".into() }; + // Block order is byte-order for the provider prompt cache: the static + // header first, then the turn-kind-stable contract and rules, then the + // session-stable block, and the volatile per-turn data last. Everything + // above the "Required card kind" line must stay free of per-turn values. format!( - r#"Return exactly one JSON Loopbiotic op. No markdown. No prose. - + r#"{PROMPT_STATIC_HEADER} Allowed ops: {output_contract} Rules: -- Required card kind: {expected_kind}. Return that exact kind. -- Patch file paths must be relative. {turn_rules} Session prompt: {prompt} +Mode: {mode} + +Required card kind: {expected_kind}. Return that exact kind. Completed local steps: {completed_steps} Known findings and signals (do not repeat): {known_observations} -Mode: {mode} Action: {action} Last card: {last} {source_context}"#, @@ -657,6 +666,64 @@ mod tests { assert!(identity.models.is_empty()); } + #[test] + fn prompt_keeps_a_stable_prefix_across_turns_of_one_session() { + let turn_a = request(); + let mut turn_b = request(); + turn_b.action = BackendAction::User(Action::Follow); + turn_b + .session + .completed_steps + .push("renamed the helper".into()); + turn_b + .session + .known_observations + .push("the guard drops zero".into()); + turn_b.session.card_count = 3; + turn_b.session.last_summary = Some("Renamed the helper".into()); + turn_b.context.buffer_text = "changed source payload".into(); + turn_b.context.cursor.line = 12; + + let a = prompt(&turn_a, true); + let b = prompt(&turn_b, true); + + // The static header, the turn-kind-stable contract and rules, and the + // session-stable block must stay byte-identical between turns of the + // same kind; volatile data may only start at the required-kind line. + let stable_block_len = a.find("Required card kind").expect("kind line present"); + assert_eq!(Some(stable_block_len), b.find("Required card kind")); + let shared = crate::common_prefix_len(&a, &b); + assert!( + shared >= stable_block_len, + "volatile bytes leaked into the stable prefix: shared {shared} < stable {stable_block_len}\nA: {a}\nB: {b}" + ); + } + + #[test] + fn prompt_static_block_is_stable_across_sessions() { + let session_a = request(); + let mut session_b = request(); + session_b.session.id = "s_9".into(); + session_b.session.prompt = "add retry logic to the fetcher".into(); + session_b.action = BackendAction::User(Action::Follow); + session_b.context.buffer_text = "other payload".into(); + + let a = prompt(&session_a, true); + let b = prompt(&session_b, true); + + // Everything before the session block — the static header plus the + // turn-kind-stable contract and rules — must be byte-identical across + // sessions running the same turn kind. + assert!(a.starts_with(PROMPT_STATIC_HEADER)); + let static_block_len = a.find("Session prompt:").expect("session line present"); + assert_eq!(Some(static_block_len), b.find("Session prompt:")); + let shared = crate::common_prefix_len(&a, &b); + assert!( + shared >= static_block_len, + "session bytes leaked into the static block: shared {shared} < static {static_block_len}" + ); + } + #[test] fn unchanged_context_is_not_repeated_in_thread_prompt() { let request = request(); diff --git a/rust/crates/loopbiotic_backends/src/generic.rs b/rust/crates/loopbiotic_backends/src/generic.rs index 4f14d15..cd9c3b7 100644 --- a/rust/crates/loopbiotic_backends/src/generic.rs +++ b/rust/crates/loopbiotic_backends/src/generic.rs @@ -63,6 +63,10 @@ impl GenericCliBackend { } } +/// The op contract sent on every turn. A `const` so it can never interpolate +/// volatile data: it opens the prompt and anchors the provider prompt cache. +const GENERIC_API_CONTRACT: &str = "Return one JSON Loopbiotic op only. No prose. Ops: hypothesis(title,claim,evidence,next), finding(title,finding,location,annotation), patch(title,explanation,goal_complete,plan,patches), choice(title,question,options), deny(title,reason,location), open_location(reason,location), summary(title,summary,changed_files), error(title,message). choice.options items are {id,label,action} objects; action is one of follow|why|fix|other_lead|retry|edit_prompt|open|run_check|next|stop. Use deny when you cannot or should not proceed (ambiguous prompt, missing information, out-of-scope request); reason is shown to the user. Outside goal completion, return open_location when the change belongs in a different file than the supplied buffer. Goal slices may patch any file inspected with read-only tools. error is only for technical failures. limits.expected, when set, is the required op (deny always allowed; choice also allowed for hypothesis/finding); when null, choose the best fitting op and ask via choice when ambiguous. Patch only for fix. patch.diff must be unified diff hunks starting with @@. Unused schema fields null."; + pub(crate) fn generic_prompt(req: &BackendRequest) -> String { let mut rules = vec![ json!( @@ -81,6 +85,8 @@ pub(crate) fn generic_prompt(req: &BackendRequest) -> String { "Explain why the next coherent block matters and return control to the user after that step." ), ]; + // Goal rules append after the static base so the shared byte prefix of + // the rules array survives across goal and non-goal turns. if req.card_contract.allow_goal_completion { rules.push(json!( "Goal turn: return exactly one file's complete patch per response — the most foundational unresolved file first — plus plan {remaining:[{file,summary}],complete}. Set plan.complete true only on the final slice; remaining is then empty. Use up to limits.hunks_per_patch hunks and limits.changed_lines per hunk for that one file; Loopbiotic verifies and reviews the slice locally, then asks for the next planned slice." @@ -92,9 +98,18 @@ pub(crate) fn generic_prompt(req: &BackendRequest) -> String { "If limits.goal_completion is true and limits.expected is finding, explain why the pending hunk is the right next step without replacing it or advancing the goal." )); } - let value = json!({ - "api": "Return one JSON Loopbiotic op only. No prose. Ops: hypothesis(title,claim,evidence,next), finding(title,finding,location,annotation), patch(title,explanation,goal_complete,plan,patches), choice(title,question,options), deny(title,reason,location), open_location(reason,location), summary(title,summary,changed_files), error(title,message). choice.options items are {id,label,action} objects; action is one of follow|why|fix|other_lead|retry|edit_prompt|open|run_check|next|stop. Use deny when you cannot or should not proceed (ambiguous prompt, missing information, out-of-scope request); reason is shown to the user. Outside goal completion, return open_location when the change belongs in a different file than the supplied buffer. Goal slices may patch any file inspected with read-only tools. error is only for technical failures. limits.expected, when set, is the required op (deny always allowed; choice also allowed for hypothesis/finding); when null, choose the best fitting op and ask via choice when ambiguous. Patch only for fix. patch.diff must be unified diff hunks starting with @@. Unused schema fields null.", - "stream": { + + // Field order is byte-order: static contract first, session-stable next, + // volatile per-turn data last, so a provider prompt cache can reuse the + // longest possible prefix between one-shot requests. `ordered_json_object` + // preserves this order; `json!` alone would sort keys alphabetically and + // lead with the volatile action. + let fields: Vec<(&str, serde_json::Value)> = vec![ + // Static: identical bytes across all turns and sessions. + ("api", json!(GENERIC_API_CONTRACT)), + ( + "stream", + json!({ "protocol": "ndjson", "progress": {"t": "loopbiotic_progress", "phase": "short phase", "message": "short user-visible activity summary"}, "result": {"t": "loopbiotic_result", "result": "the final Loopbiotic op JSON object"}, @@ -103,31 +118,42 @@ pub(crate) fn generic_prompt(req: &BackendRequest) -> String { "Progress messages must be concise user-visible summaries of work, never hidden reasoning or private chain-of-thought.", "The final output may instead be a raw Loopbiotic op for backwards compatibility." ] - }, - "rules": rules, - "limits": { + }), + ), + ("rules", serde_json::Value::Array(rules)), + // Session-stable: identical bytes on every turn of one session. + ( + "s", + json!({ + "id": req.session.id, + "mode": req.session.mode, + "p": req.session.prompt, + }), + ), + // Turn-kind-stable: constant across all turns of the same kind. + ( + "limits", + json!({ "one": req.card_contract.one_card_only, "max": req.card_contract.max_body_chars, "patch_files": req.card_contract.max_patch_files, "hunks_per_patch": req.card_contract.max_hunks_per_patch, "changed_lines": req.card_contract.max_changed_lines, "goal_completion": req.card_contract.allow_goal_completion, - "expected": req.card_contract.expected_kind - }, - "s": { - "id": req.session.id, - "p": req.session.prompt, - "completed_steps": req.session.completed_steps, - "known_observations": req.session.known_observations, - "mode": req.session.mode, - "n": req.session.card_count, - "last": req.session.last_summary - }, - "a": action_value(&req.action), - "ctx": crate::backend_context(&req.context) - }); + "expected": req.card_contract.expected_kind, + }), + ), + // Append-only within a session. + ("completed_steps", json!(req.session.completed_steps)), + ("known_observations", json!(req.session.known_observations)), + // Volatile: changes every turn. + ("a", action_value(&req.action)), + ("last", json!(req.session.last_summary)), + ("n", json!(req.session.card_count)), + ("ctx", crate::backend_context(&req.context)), + ]; - serde_json::to_string(&value).unwrap_or_default() + crate::support::ordered_json_object(&fields) } #[async_trait] @@ -377,6 +403,63 @@ mod tests { assert!(goal.contains("next planned file slice")); } + #[test] + fn generic_prompt_keeps_a_stable_prefix_across_turns_of_one_session() { + let turn_a = crate::test_request(); + let mut turn_b = crate::test_request(); + turn_b.action = crate::BackendAction::User(loopbiotic_protocol::Action::Follow); + turn_b + .session + .completed_steps + .push("renamed the helper".into()); + turn_b + .session + .known_observations + .push("the guard drops zero".into()); + turn_b.session.card_count = 4; + turn_b.session.last_summary = Some("Renamed the helper".into()); + turn_b.context.buffer_text = "fn main() { changed() }".into(); + + let a = generic_prompt(&turn_a); + let b = generic_prompt(&turn_b); + + // The static contract (api, stream, rules), the session-stable `s` + // block, and the turn-kind-stable `limits` block must stay + // byte-identical between turns; only the trailing per-turn data may + // differ, or one-shot requests lose every provider cache hit. + let stable_block_len = a.find("\"completed_steps\"").expect("lists present"); + assert_eq!(Some(stable_block_len), b.find("\"completed_steps\"")); + let shared = crate::common_prefix_len(&a, &b); + assert!( + shared >= stable_block_len, + "volatile bytes leaked into the stable prefix: shared {shared} < stable {stable_block_len}" + ); + } + + #[test] + fn generic_prompt_static_block_is_stable_across_sessions() { + let session_a = crate::test_request(); + let mut session_b = crate::test_request(); + session_b.session.id = "s_2".into(); + session_b.session.prompt = "add retry logic to the fetcher".into(); + session_b.action = crate::BackendAction::User(loopbiotic_protocol::Action::Fix); + session_b.context.buffer_text = "fn other() {}".into(); + + let a = generic_prompt(&session_a); + let b = generic_prompt(&session_b); + + // The whole static block — everything before the session-stable `s` + // key — must be byte-identical across sessions of the same turn kind. + let static_block_len = a.find(",\"s\":{").expect("session block present"); + assert_eq!(Some(static_block_len), b.find(",\"s\":{")); + let shared = crate::common_prefix_len(&a, &b); + assert!( + shared >= static_block_len, + "session bytes leaked into the static block: shared {shared} < static {static_block_len}" + ); + assert!(a.starts_with("{\"api\":")); + } + #[test] fn extracts_json_card() { let output = "text {\"kind\":\"error\",\"id\":\"c_1\",\"title\":\"Nope\",\"message\":\"bad\",\"actions\":[\"retry\",\"stop\"]} tail"; diff --git a/rust/crates/loopbiotic_backends/src/lib.rs b/rust/crates/loopbiotic_backends/src/lib.rs index 3afb8af..a44929e 100644 --- a/rust/crates/loopbiotic_backends/src/lib.rs +++ b/rust/crates/loopbiotic_backends/src/lib.rs @@ -246,6 +246,16 @@ pub struct SessionSnapshot { pub last_summary: Option, } +/// Length of the shared byte prefix of two prompts — the part a provider +/// prompt cache can reuse across the two requests. +#[cfg(test)] +pub(crate) fn common_prefix_len(a: &str, b: &str) -> usize { + a.bytes() + .zip(b.bytes()) + .take_while(|(left, right)| left == right) + .count() +} + #[cfg(test)] pub(crate) fn test_request() -> BackendRequest { BackendRequest { diff --git a/rust/crates/loopbiotic_backends/src/support.rs b/rust/crates/loopbiotic_backends/src/support.rs index 2a630e0..23b5347 100644 --- a/rust/crates/loopbiotic_backends/src/support.rs +++ b/rust/crates/loopbiotic_backends/src/support.rs @@ -111,6 +111,28 @@ pub(crate) fn context_fingerprint(req: &BackendRequest) -> u64 { hasher.finish() } +/// Serializes `fields` as one JSON object with exactly the given key order. +/// serde_json's default map sorts keys alphabetically, so `json!` alone cannot +/// put stable keys first. Provider prompt caches key on byte-stable prefixes: +/// every byte before the first difference is cacheable, so session-stable +/// fields must serialize before every volatile byte. +pub(crate) fn ordered_json_object(fields: &[(&str, Value)]) -> String { + let mut out = String::from("{"); + for (index, (key, value)) in fields.iter().enumerate() { + if index > 0 { + out.push(','); + } + // Keys are internal identifiers; values come from serde_json itself, + // so serialization cannot fail in practice. + out.push_str(&serde_json::to_string(key).unwrap_or_default()); + out.push(':'); + out.push_str(&serde_json::to_string(value).unwrap_or_default()); + } + out.push('}'); + + out +} + pub(crate) fn action_value(action: &BackendAction) -> Value { match action { BackendAction::Start => json!({"kind": "start"}), @@ -184,6 +206,21 @@ pub(crate) fn optional_env(name: &str) -> Option { mod tests { use super::*; + #[test] + fn ordered_json_object_preserves_field_order() { + // serde_json's default map would sort these keys as a, b, z; caching + // needs the caller's stable-first order to survive serialization. + let out = ordered_json_object(&[ + ("z", json!({"k": 1})), + ("a", json!("v")), + ("b", json!([1, 2])), + ]); + + assert_eq!(out, r#"{"z":{"k":1},"a":"v","b":[1,2]}"#); + let value: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(value["z"]["k"], 1); + } + #[test] fn serializes_user_action_as_protocol_value() { let value = action_value(&BackendAction::User(Action::Fix)); From 76e81f0692f010b2e4d2999abfae9b5845db1f6b Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 18:19:51 +0200 Subject: [PATCH 31/37] feat: normalize and classify patch violations --- .../loopbiotic_harness/src/engine/tests.rs | 20 +- .../loopbiotic_harness/src/engine/turn.rs | 57 +- .../loopbiotic_harness/src/engine/validate.rs | 78 ++- rust/crates/loopbiotic_patch/src/lib.rs | 2 + .../loopbiotic_patch/src/unified_diff.rs | 54 +- rust/crates/loopbiotic_patch/src/validate.rs | 527 ++++++++++++++++-- rust/crates/loopbiotic_patch/src/violation.rs | 63 +++ rust/crates/loopbiotic_protocol/src/rpc.rs | 70 +++ tests/lua/test_safety.lua | 14 + 9 files changed, 805 insertions(+), 80 deletions(-) create mode 100644 rust/crates/loopbiotic_patch/src/violation.rs diff --git a/rust/crates/loopbiotic_harness/src/engine/tests.rs b/rust/crates/loopbiotic_harness/src/engine/tests.rs index d9766ec..ede2146 100644 --- a/rust/crates/loopbiotic_harness/src/engine/tests.rs +++ b/rust/crates/loopbiotic_harness/src/engine/tests.rs @@ -396,7 +396,12 @@ async fn converts_bad_patch_to_error_card() { panic!("expected error card"); }; - assert!(card.message.contains("diff has no hunks")); + assert!( + card.message + .contains("unexpected content before first diff hunk") + ); + assert!(result.attempts.iter().all(|attempt| attempt.violation_class + == Some(loopbiotic_protocol::ViolationClass::MalformedDiff))); let retry = engine .action(&start.session_id, Action::Retry) @@ -432,7 +437,12 @@ async fn repairs_invalid_patch_before_showing_it_to_user() { ); assert!(result.attempts[0].candidate_card.is_some()); assert_eq!(result.attempts[0].token_usage.total_tokens, 15); + assert_eq!( + result.attempts[0].violation_class, + Some(loopbiotic_protocol::ViolationClass::ContextMismatch) + ); assert_eq!(result.attempts[1].outcome, "accepted"); + assert_eq!(result.attempts[1].violation_class, None); assert_eq!(result.turn_token_usage.total_tokens, 30); } @@ -450,6 +460,8 @@ async fn preserves_wrong_card_type_and_raw_backend_output_for_fix() { assert!(card.message.contains("expected patch card")); assert!(card.message.contains("Received card kind: Finding")); assert!(card.message.contains("raw finding from backend")); + assert!(result.attempts.iter().all(|attempt| attempt.violation_class + == Some(loopbiotic_protocol::ViolationClass::KindMismatch))); let retry = engine .action(&start.session_id, Action::Retry) @@ -495,6 +507,7 @@ async fn start_returns_typed_card_when_backend_fails() { assert!(card.message.contains("backend unavailable")); assert_eq!(result.turn_token_usage, Default::default()); assert_eq!(result.attempts[0].outcome, "backend_error"); + assert_eq!(result.attempts[0].violation_class, None); assert!( result.attempts[0] .detail @@ -523,8 +536,13 @@ async fn retains_duplicate_observations_without_showing_them_again() { assert_eq!(card.annotation, None); assert_eq!(result.attempts.len(), 2); assert_eq!(result.attempts[0].outcome, "duplicate_retry"); + assert_eq!( + result.attempts[0].violation_class, + Some(loopbiotic_protocol::ViolationClass::DuplicateStep) + ); assert!(result.attempts[0].candidate_card.is_some()); assert_eq!(result.attempts[1].outcome, "accepted"); + assert_eq!(result.attempts[1].violation_class, None); let observations = &engine.get(&start.session_id).unwrap().known_observations; assert_eq!(observations.len(), 3); diff --git a/rust/crates/loopbiotic_harness/src/engine/turn.rs b/rust/crates/loopbiotic_harness/src/engine/turn.rs index 9c46e09..c98a79f 100644 --- a/rust/crates/loopbiotic_harness/src/engine/turn.rs +++ b/rust/crates/loopbiotic_harness/src/engine/turn.rs @@ -4,10 +4,12 @@ use anyhow::{Result, anyhow}; use loopbiotic_backends::{BackendAction, BackendProgress, BackendResponse, ProgressReporter}; -use loopbiotic_patch::{PatchCoherence, PatchNormalizer, PatchValidator}; +use loopbiotic_patch::{ + PatchCoherence, PatchNormalizer, PatchValidator, violation, violation_class, +}; use loopbiotic_protocol::{ Action, AgentAttempt, Card, ContextBundle, ErrorCard, MAX_GOAL_CHANGED_LINES, - MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, TokenUsage, + MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, TokenUsage, ViolationClass, }; use crate::session::Session; @@ -61,6 +63,7 @@ impl Engine { outcome: "backend_error".into(), token_usage: TokenUsage::default(), detail: Some(detail), + violation_class: None, candidate_card: None, activities: vec![], }); @@ -99,6 +102,7 @@ impl Engine { "location_granted", Some(request.location.file.display().to_string()), attempt_usage, + None, false, )); session.context = granted.clone(); @@ -115,6 +119,7 @@ impl Engine { "location_declined", Some(request.location.file.display().to_string()), attempt_usage, + None, false, )); response.card = Card::Deny(loopbiotic_protocol::DenyCard { @@ -143,6 +148,7 @@ impl Engine { }, Some(reason.clone()), attempt_usage, + Some(ViolationClass::DuplicateStep), true, )); if attempt < 2 { @@ -178,6 +184,7 @@ impl Engine { }, Some(reason.clone()), attempt_usage, + Some(ViolationClass::DuplicateStep), true, )); if attempt < 2 { @@ -211,6 +218,7 @@ impl Engine { } .map(|()| PatchCoherence::annotate(&mut candidate)); if let Err(error) = validation { + let class = violation_class(&error).unwrap_or(ViolationClass::Other); let detail = error.to_string(); attempts.push(agent_attempt( attempt + 1, @@ -222,6 +230,7 @@ impl Engine { }, Some(detail.clone()), attempt_usage, + Some(class), true, )); if attempt < 2 { @@ -259,6 +268,7 @@ impl Engine { "accepted", None, attempt_usage, + None, false, )); response.metadata.token_usage = token_usage; @@ -304,7 +314,10 @@ impl Engine { // Guarded by the `matches!` early return above; degrade to a // contract failure (retry/rejected card) instead of panicking if // that guard ever drifts. - return Err(anyhow!("goal batch candidate is no longer a patch card")); + return Err(violation( + ViolationClass::IncoherentBatch, + "goal batch candidate is no longer a patch card", + )); }; for index in 0..card.patches.len() { @@ -316,13 +329,21 @@ impl Engine { } else { None } - .ok_or_else(|| anyhow!("editor source is unavailable for {}", file.display()))?; + .ok_or_else(|| { + violation( + ViolationClass::IncoherentBatch, + format!("editor source is unavailable for {}", file.display()), + ) + })?; if !context_targets(&source, &file) { - return Err(anyhow!( - "editor returned {} while validating {}", - source.file.display(), - file.display() + return Err(violation( + ViolationClass::IncoherentBatch, + format!( + "editor returned {} while validating {}", + source.file.display(), + file.display() + ), )); } @@ -337,20 +358,26 @@ impl Engine { actions: card.actions.clone(), }); PatchNormalizer::normalize_card(&mut single, &source) - .map_err(|error| anyhow!("{}: {error}", file.display()))?; + .map_err(|error| error.context(file.display().to_string()))?; PatchValidator::validate_card_against_context(&single, &source) - .map_err(|error| anyhow!("{}: {error}", file.display()))?; + .map_err(|error| error.context(file.display().to_string()))?; let Card::Patch(single) = single else { // Constructed as a patch card just above and normalization // never changes the card kind; degrade instead of panicking. - return Err(anyhow!( - "{}: patch normalization changed the card kind", - file.display() + return Err(violation( + ViolationClass::IncoherentBatch, + format!( + "{}: patch normalization changed the card kind", + file.display() + ), )); }; card.patches[index] = single.patches.into_iter().next().ok_or_else(|| { - anyhow!("{}: patch normalization dropped the hunk", file.display()) + violation( + ViolationClass::IncoherentBatch, + format!("{}: patch normalization dropped the hunk", file.display()), + ) })?; } @@ -364,6 +391,7 @@ fn agent_attempt( outcome: &str, detail: Option, token_usage: TokenUsage, + violation_class: Option, include_candidate: bool, ) -> AgentAttempt { AgentAttempt { @@ -372,6 +400,7 @@ fn agent_attempt( outcome: outcome.into(), token_usage, detail, + violation_class, candidate_card: include_candidate.then(|| response.card.clone()), activities: response.metadata.activities.clone(), } diff --git a/rust/crates/loopbiotic_harness/src/engine/validate.rs b/rust/crates/loopbiotic_harness/src/engine/validate.rs index 540b3a3..9844c6e 100644 --- a/rust/crates/loopbiotic_harness/src/engine/validate.rs +++ b/rust/crates/loopbiotic_harness/src/engine/validate.rs @@ -1,11 +1,11 @@ //! Structural validation of backend cards, patch targets, and editor apply //! results before they are accepted into a session. -use anyhow::{Result, anyhow}; -use loopbiotic_patch::PatchValidator; +use anyhow::Result; +use loopbiotic_patch::{PatchValidator, violation}; use loopbiotic_protocol::{ Card, ContextBundle, MAX_GOAL_CHANGED_LINES, MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, - PatchApplyResult, + PatchApplyResult, ViolationClass, }; use crate::session::Session; @@ -41,7 +41,9 @@ pub(super) fn validate_backend_card( } validate_patch_target(card, context)?; PatchValidator::validate_card_against_context(card, context)?; - next_state.validate(card)?; + next_state + .validate(card) + .map_err(|error| violation(ViolationClass::KindMismatch, error.to_string()))?; Ok(()) } @@ -62,10 +64,13 @@ fn validate_patch_target(card: &Card, context: &ContextBundle) -> Result<()> { if let Some(patch) = card.patches.first() && patch.file != expected { - return Err(anyhow!( - "patch targets {}, but the accepted source location is {}; open that location before Fix", - patch.file.display(), - expected.display() + return Err(violation( + ViolationClass::WrongFile, + format!( + "patch targets {}, but the accepted source location is {}; open that location before Fix", + patch.file.display(), + expected.display() + ), )); } @@ -87,7 +92,7 @@ pub(super) fn context_targets(context: &ContextBundle, file: &std::path::Path) - pub(super) fn validate_one_card(card: &Card) -> Result<()> { if card.id().trim().is_empty() { - return Err(anyhow!("card id is empty")); + return Err(violation(ViolationClass::MissingField, "card id is empty")); } match card { @@ -134,7 +139,10 @@ pub(super) fn validate_one_card(card: &Card) -> Result<()> { require_text("card title", &card.title)?; require_text("choice question", &card.question)?; if card.options.is_empty() { - return Err(anyhow!("choice card has no options")); + return Err(violation( + ViolationClass::MissingField, + "choice card has no options", + )); } for option in &card.options { require_text("choice option id", &option.id)?; @@ -173,7 +181,10 @@ pub(super) fn validate_one_card(card: &Card) -> Result<()> { } if !matches!(card, Card::Choice(_) | Card::Summary(_)) && card.actions().is_empty() { - return Err(anyhow!("card has no actions")); + return Err(violation( + ViolationClass::MissingField, + "card has no actions", + )); } Ok(()) @@ -181,7 +192,10 @@ pub(super) fn validate_one_card(card: &Card) -> Result<()> { fn require_text(field: &str, value: &str) -> Result<()> { if value.trim().is_empty() { - return Err(anyhow!("{field} is empty")); + return Err(violation( + ViolationClass::MissingField, + format!("{field} is empty"), + )); } Ok(()) @@ -194,10 +208,16 @@ fn validate_location( label: &str, ) -> Result<()> { if file.as_os_str().is_empty() { - return Err(anyhow!("{label} file is empty")); + return Err(violation( + ViolationClass::MissingField, + format!("{label} file is empty"), + )); } if line == 0 || column == 0 { - return Err(anyhow!("{label} line and column must start at 1")); + return Err(violation( + ViolationClass::MissingField, + format!("{label} line and column must start at 1"), + )); } Ok(()) @@ -205,14 +225,19 @@ fn validate_location( pub(super) fn validate_apply_result(session: &Session, result: &PatchApplyResult) -> Result<()> { let Some(Card::Patch(card)) = session.cards.last() else { - return Err(anyhow!("patch state has no current patch card")); + return Err(violation( + ViolationClass::Other, + "patch state has no current patch card", + )); }; if result.card_id != card.id { - return Err(anyhow!( - "apply result targets card {}, but current patch card is {}", - result.card_id, - card.id + return Err(violation( + ViolationClass::Other, + format!( + "apply result targets card {}, but current patch card is {}", + result.card_id, card.id + ), )); } @@ -222,8 +247,9 @@ pub(super) fn validate_apply_result(session: &Session, result: &PatchApplyResult .map(|patch| patch.id.clone()) .collect::>(); if result.patch_ids != expected_patch_ids { - return Err(anyhow!( - "apply result patch ids do not match the current patch card" + return Err(violation( + ViolationClass::Other, + "apply result patch ids do not match the current patch card", )); } @@ -233,13 +259,15 @@ pub(super) fn validate_apply_result(session: &Session, result: &PatchApplyResult .map(|patch| patch.file.clone()) .collect::>(); if result.accepted && result.changed_files != expected_files { - return Err(anyhow!( - "accepted apply result changed files do not match the current patch card" + return Err(violation( + ViolationClass::Other, + "accepted apply result changed files do not match the current patch card", )); } if !result.accepted && !result.changed_files.is_empty() { - return Err(anyhow!( - "rejected apply result cannot contain changed files" + return Err(violation( + ViolationClass::Other, + "rejected apply result cannot contain changed files", )); } diff --git a/rust/crates/loopbiotic_patch/src/lib.rs b/rust/crates/loopbiotic_patch/src/lib.rs index d163f46..4307aca 100644 --- a/rust/crates/loopbiotic_patch/src/lib.rs +++ b/rust/crates/loopbiotic_patch/src/lib.rs @@ -1,7 +1,9 @@ pub mod apply; pub mod unified_diff; pub mod validate; +pub mod violation; pub use apply::*; pub use unified_diff::*; pub use validate::*; +pub use violation::*; diff --git a/rust/crates/loopbiotic_patch/src/unified_diff.rs b/rust/crates/loopbiotic_patch/src/unified_diff.rs index 084e7a3..fa6e655 100644 --- a/rust/crates/loopbiotic_patch/src/unified_diff.rs +++ b/rust/crates/loopbiotic_patch/src/unified_diff.rs @@ -1,4 +1,7 @@ -use anyhow::{Result, anyhow}; +use anyhow::Result; +use loopbiotic_protocol::ViolationClass; + +use crate::violation::violation; #[derive(Clone, Debug, Eq, PartialEq)] pub struct UnifiedDiff { @@ -49,7 +52,13 @@ impl UnifiedDiff { } let Some(hunk) = current.as_mut() else { - continue; + if line.trim().is_empty() { + continue; + } + return Err(violation( + ViolationClass::MalformedDiff, + format!("unexpected content before first diff hunk: {line}"), + )); }; if let Some(text) = line.strip_prefix(' ') { @@ -61,7 +70,10 @@ impl UnifiedDiff { } else if line == "\\ No newline at end of file" { continue; } else { - return Err(anyhow!("invalid diff line {line}")); + return Err(violation( + ViolationClass::MalformedDiff, + format!("invalid diff line {line}"), + )); } } @@ -70,7 +82,10 @@ impl UnifiedDiff { } if hunks.is_empty() { - return Err(anyhow!("diff has no hunks")); + return Err(violation( + ViolationClass::MalformedDiff, + "diff has no hunks", + )); } Ok(Self { hunks }) @@ -104,7 +119,10 @@ fn parse_hunk(line: &str) -> Result { let parts = line.split_whitespace().collect::>(); if parts.len() < 3 { - return Err(anyhow!("invalid hunk header")); + return Err(violation( + ViolationClass::MalformedDiff, + "invalid hunk header", + )); } let (old_start, old_len) = parse_range(parts[1], '-')?; @@ -122,13 +140,18 @@ fn parse_hunk(line: &str) -> Result { fn parse_range(value: &str, prefix: char) -> Result<(usize, usize)> { let value = value .strip_prefix(prefix) - .ok_or_else(|| anyhow!("invalid hunk range"))?; + .ok_or_else(|| violation(ViolationClass::MalformedDiff, "invalid hunk range"))?; let mut parts = value.split(','); let start = parts .next() - .ok_or_else(|| anyhow!("missing range start"))? - .parse()?; - let len = parts.next().unwrap_or("1").parse()?; + .ok_or_else(|| violation(ViolationClass::MalformedDiff, "missing range start"))? + .parse() + .map_err(|_| violation(ViolationClass::MalformedDiff, "invalid hunk range start"))?; + let len = parts + .next() + .unwrap_or("1") + .parse() + .map_err(|_| violation(ViolationClass::MalformedDiff, "invalid hunk range length"))?; Ok((start, len)) } @@ -145,4 +168,17 @@ mod tests { assert_eq!(parsed.hunks.len(), 1); assert_eq!(parsed.hunks[0].old_start, 1); } + + #[test] + fn rejects_unhandled_content_before_the_first_hunk() { + let error = + UnifiedDiff::parse("--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\n") + .unwrap_err(); + + assert_eq!( + crate::violation_class(&error), + Some(ViolationClass::MalformedDiff) + ); + assert!(error.to_string().contains("before first diff hunk")); + } } diff --git a/rust/crates/loopbiotic_patch/src/validate.rs b/rust/crates/loopbiotic_patch/src/validate.rs index aaae205..ae1a0f1 100644 --- a/rust/crates/loopbiotic_patch/src/validate.rs +++ b/rust/crates/loopbiotic_patch/src/validate.rs @@ -1,11 +1,14 @@ use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; -use anyhow::{Result, anyhow}; +use anyhow::Result; use loopbiotic_protocol::{ Card, ContextBundle, FilePatch, MAX_CHANGED_LINES, MAX_HUNKS_PER_PATCH, MAX_PATCH_FILES, + ViolationClass, }; use crate::unified_diff::{DiffLine, UnifiedDiff}; +use crate::violation::violation; pub struct PatchValidator; pub struct PatchNormalizer; @@ -63,8 +66,18 @@ impl PatchNormalizer { return Ok(()); }; let source = context.buffer_text.lines().collect::>(); + let expected_file = workspace_relative_file(context); for patch in &mut card.patches { + normalize_patch_file(&mut patch.file, Some(&expected_file)); + patch.diff = strip_diff_envelope(&patch.diff, &patch.file)?; + // Models drafting against an LF buffer sometimes emit CRLF line + // endings; drop the mechanical `\r` so added lines match the + // buffer's endings. A buffer that itself carries `\r` makes the + // intent ambiguous, so it is left for the retry path. + if patch.diff.contains('\r') && !context.buffer_text.contains('\r') { + patch.diff = patch.diff.replace("\r\n", "\n"); + } let mut diff = UnifiedDiff::parse(&patch.diff)?; for hunk in &mut diff.hunks { // Models routinely miscount the `@@ -a,b +c,d @@` ranges. The @@ -90,8 +103,9 @@ impl PatchNormalizer { hunk.new_start = context.buffer_start_line; None } else { - return Err(anyhow!( - "patch hunk without source context can only create an empty file" + return Err(violation( + ViolationClass::ContextMismatch, + "patch hunk without source context can only create an empty file", )); } } else { @@ -107,13 +121,15 @@ impl PatchNormalizer { match matches.as_slice() { [start] => *start, [] => { - return Err(anyhow!( - "patch context was not found in the supplied buffer" + return Err(violation( + ViolationClass::ContextMismatch, + "patch context was not found in the supplied buffer", )); } _ => { - return Err(anyhow!( - "patch context is ambiguous in the supplied buffer" + return Err(violation( + ViolationClass::ContextMismatch, + "patch context is ambiguous in the supplied buffer", )); } } @@ -146,9 +162,17 @@ impl PatchNormalizer { let delta = hunk.new_start as isize - hunk.old_start as isize; let corrected_new_start = corrected_old_start .checked_add_signed(delta) - .ok_or_else(|| anyhow!("corrected patch coordinates are outside the file"))?; + .ok_or_else(|| { + violation( + ViolationClass::HunkHeaderMismatch, + "corrected patch coordinates are outside the file", + ) + })?; if corrected_new_start == 0 { - return Err(anyhow!("corrected patch coordinates must start at 1")); + return Err(violation( + ViolationClass::HunkHeaderMismatch, + "corrected patch coordinates must start at 1", + )); } hunk.old_start = corrected_old_start; @@ -170,6 +194,10 @@ impl PatchNormalizer { }; for patch in &mut card.patches { + // No context is available here, so only the always-safe repairs + // run: `./` on the target path and the diff envelope. + normalize_patch_file(&mut patch.file, None); + patch.diff = strip_diff_envelope(&patch.diff, &patch.file)?; let mut diff = UnifiedDiff::parse(&patch.diff)?; for hunk in &mut diff.hunks { recompute_hunk_lengths(hunk); @@ -202,12 +230,18 @@ impl PatchValidator { }; if card.patches.is_empty() { - return Err(anyhow!("patch card has no patches")); + return Err(violation( + ViolationClass::MissingField, + "patch card has no patches", + )); } if card.patches.len() > max_patch_files { - return Err(anyhow!( - "patch card changes {} files; maximum is {max_patch_files}", - card.patches.len(), + return Err(violation( + ViolationClass::MultiHunk, + format!( + "patch card changes {} files; maximum is {max_patch_files}", + card.patches.len(), + ), )); } @@ -228,22 +262,31 @@ impl PatchValidator { max_changed_lines: usize, ) -> Result<()> { if patch.id.trim().is_empty() { - return Err(anyhow!("patch id is empty")); + return Err(violation(ViolationClass::MissingField, "patch id is empty")); } if patch.file.as_os_str().is_empty() { - return Err(anyhow!("patch file is empty")); + return Err(violation( + ViolationClass::MissingField, + "patch file is empty", + )); } if patch.file.is_absolute() { - return Err(anyhow!("patch file must be relative")); + return Err(violation( + ViolationClass::WrongFile, + "patch file must be relative", + )); } let diff = UnifiedDiff::parse(&patch.diff)?; if diff.hunks.len() > max_hunks_per_patch { - return Err(anyhow!( - "patch has {} hunks; maximum is {max_hunks_per_patch}", - diff.hunks.len(), + return Err(violation( + ViolationClass::MultiHunk, + format!( + "patch has {} hunks; maximum is {max_hunks_per_patch}", + diff.hunks.len(), + ), )); } @@ -267,7 +310,10 @@ impl PatchValidator { .old_start .checked_sub(context.buffer_start_line) .ok_or_else(|| { - anyhow!("patch hunk starts before the supplied buffer excerpt") + violation( + ViolationClass::ContextMismatch, + "patch hunk starts before the supplied buffer excerpt", + ) })?; let expected = hunk .lines @@ -282,21 +328,28 @@ impl PatchValidator { if hunk.old_len == 0 && source.is_empty() && start == 0 { continue; } - return Err(anyhow!( - "patch hunk without source context can only create an empty file" + return Err(violation( + ViolationClass::ContextMismatch, + "patch hunk without source context can only create an empty file", )); } for (offset, expected) in expected.into_iter().enumerate() { let line = context.buffer_start_line + start + offset; let actual = source.get(start + offset).copied().ok_or_else(|| { - anyhow!( - "patch source context at line {line} is outside the supplied buffer" + violation( + ViolationClass::ContextMismatch, + format!( + "patch source context at line {line} is outside the supplied buffer" + ), ) })?; if actual != expected { - return Err(anyhow!( - "patch source context mismatch at line {line}: expected {expected:?}, got {actual:?}" + return Err(violation( + ViolationClass::ContextMismatch, + format!( + "patch source context mismatch at line {line}: expected {expected:?}, got {actual:?}" + ), )); } } @@ -321,18 +374,27 @@ fn validate_hunk_counts(hunk: &crate::Hunk, max_changed_lines: usize) -> Result< if old_count == 0 { if hunk.old_len != 0 { - return Err(anyhow!("hunk has no source context")); + return Err(violation( + ViolationClass::HunkHeaderMismatch, + "hunk has no source context", + )); } if !hunk .lines .iter() .any(|line| matches!(line, DiffLine::Add(_))) { - return Err(anyhow!("new-file hunk has no added lines")); + return Err(violation( + ViolationClass::MalformedDiff, + "new-file hunk has no added lines", + )); } } if old_count != hunk.old_len || new_count != hunk.new_len { - return Err(anyhow!("hunk header counts do not match its lines")); + return Err(violation( + ViolationClass::HunkHeaderMismatch, + "hunk header counts do not match its lines", + )); } let changed_lines = hunk @@ -341,8 +403,9 @@ fn validate_hunk_counts(hunk: &crate::Hunk, max_changed_lines: usize) -> Result< .filter(|line| matches!(line, DiffLine::Remove(_) | DiffLine::Add(_))) .count(); if changed_lines > max_changed_lines { - return Err(anyhow!( - "hunk changes {changed_lines} lines; maximum is {max_changed_lines}" + return Err(violation( + ViolationClass::MultiHunk, + format!("hunk changes {changed_lines} lines; maximum is {max_changed_lines}"), )); } @@ -361,6 +424,170 @@ fn render_diff(diff: &UnifiedDiff) -> String { diff.render() } +/// The workspace-relative form of the context's file — the form patch cards +/// are required to target. +fn workspace_relative_file(context: &ContextBundle) -> PathBuf { + if context.file.is_absolute() { + context + .file + .strip_prefix(&context.cwd) + .unwrap_or(&context.file) + .to_path_buf() + } else { + context.file.clone() + } +} + +/// Repairs mechanical prefixes on a patch target path. A leading `./` is an +/// identity and always dropped; git's `a/`/`b/` prefixes are only dropped +/// when the stripped path is exactly the expected workspace-relative target, +/// since a project may genuinely contain an `a/` or `b/` directory. +fn normalize_patch_file(file: &mut PathBuf, expected: Option<&Path>) { + if let Ok(stripped) = file.strip_prefix("./") + && !stripped.as_os_str().is_empty() + { + *file = stripped.to_path_buf(); + } + if let Some(expected) = expected + && file != expected + && let Some(stripped) = strip_diff_path_prefix(file) + && stripped == expected + { + *file = stripped; + } +} + +/// Returns the path without git's mechanical `a/` or `b/` diff prefix, or +/// `None` when it carries no such prefix. +pub fn strip_diff_path_prefix(file: &Path) -> Option { + ["a", "b"].iter().find_map(|prefix| { + file.strip_prefix(prefix) + .ok() + .filter(|stripped| !stripped.as_os_str().is_empty()) + .map(Path::to_path_buf) + }) +} + +/// Strips a mechanical model-added envelope from a diff: markdown code fences +/// wrapping it, and a leading git-style header block whose paths already name +/// `file` (modulo `a/`, `b/`, and `./` prefixes) or `/dev/null`. Anything +/// else before the first hunk — prose, headers naming another file, +/// rename/copy headers — is rejected so it cannot be silently discarded by a +/// parser or applied to the wrong target. +fn strip_diff_envelope(diff: &str, file: &Path) -> Result { + let lines = diff.lines().collect::>(); + let mut start = 0; + let mut end = lines.len(); + + while start < end && lines[start].trim().is_empty() { + start += 1; + } + while end > start && lines[end - 1].trim().is_empty() { + end -= 1; + } + + let has_opening_fence = start < end && is_code_fence(lines[start]); + let has_closing_fence = end > start && lines[end - 1].trim() == "```"; + match (has_opening_fence, has_closing_fence) { + (true, true) => { + start += 1; + end -= 1; + } + (false, false) => {} + _ => { + return Err(violation( + ViolationClass::MalformedDiff, + "diff has an unmatched markdown code fence", + )); + } + } + + while start < end && lines[start].trim().is_empty() { + start += 1; + } + while end > start && lines[end - 1].trim().is_empty() { + end -= 1; + } + + // A leading git header block, verified line by line. + while start < end && !lines[start].starts_with("@@") { + let Some(paths) = header_line_paths(lines[start]) else { + return Err(violation( + ViolationClass::MalformedDiff, + format!( + "unexpected content before first diff hunk: {}", + lines[start] + ), + )); + }; + if !paths.iter().all(|path| header_path_matches(path, file)) { + return Err(violation( + ViolationClass::WrongFile, + format!( + "diff header targets a different file than {}", + file.display() + ), + )); + } + start += 1; + } + + if !lines.get(start).is_some_and(|line| line.starts_with("@@")) { + return Err(violation( + ViolationClass::MalformedDiff, + "diff has no hunks", + )); + } + + let mut body = lines[start..end].join("\n"); + body.push('\n'); + Ok(body) +} + +fn is_code_fence(line: &str) -> bool { + line.trim() + .strip_prefix("```") + .is_some_and(|language| language.chars().all(|c| c.is_ascii_alphanumeric())) +} + +/// Recognizes one line of a git-style diff header, returning the paths it +/// names (empty when it names none). `None` means the line is not a purely +/// mechanical header — rename/copy headers carry intent and are not stripped. +fn header_line_paths(line: &str) -> Option> { + if let Some(rest) = line.strip_prefix("diff --git ") { + return Some(rest.split_whitespace().collect()); + } + if let Some(rest) = line + .strip_prefix("--- ") + .or_else(|| line.strip_prefix("+++ ")) + { + // The classic format may append a tab-separated timestamp. + return Some(vec![rest.split('\t').next().unwrap_or(rest).trim()]); + } + + const PATHLESS: &[&str] = &[ + "index ", + "new file mode ", + "deleted file mode ", + "old mode ", + "new mode ", + ]; + PATHLESS + .iter() + .any(|prefix| line.starts_with(prefix)) + .then(Vec::new) +} + +fn header_path_matches(path: &str, file: &Path) -> bool { + if path == "/dev/null" { + return true; + } + let path = Path::new(path); + let path = path.strip_prefix("./").unwrap_or(path); + + path == file || strip_diff_path_prefix(path).is_some_and(|stripped| stripped == file) +} + fn recompute_hunk_lengths(hunk: &mut crate::Hunk) { hunk.old_len = hunk .lines @@ -819,6 +1046,244 @@ mod tests { assert!(error.to_string().contains("ambiguous")); } + fn patch_card(patch: FilePatch) -> Card { + Card::Patch(loopbiotic_protocol::PatchCard { + id: "c_1".into(), + title: "Fix".into(), + explanation: "Fix.".into(), + warnings: vec![], + goal_complete: false, + plan: None, + patches: vec![patch], + actions: vec![loopbiotic_protocol::Action::Apply], + }) + } + + fn file_patch(file: &str, diff: &str) -> FilePatch { + FilePatch { + id: "p_1".into(), + file: PathBuf::from(file), + diff: diff.into(), + explanation: "Fix.".into(), + } + } + + fn context(file: &str, buffer_text: &str) -> ContextBundle { + ContextBundle { + cwd: PathBuf::from("/tmp/project"), + file: PathBuf::from(file), + cursor: loopbiotic_protocol::Cursor { line: 1, column: 1 }, + selection: None, + buffer_text: buffer_text.into(), + buffer_start_line: 1, + diagnostics: vec![], + hints: vec![], + artifacts: vec![], + report: None, + } + } + + fn normalized_diff(card: Card) -> String { + let Card::Patch(card) = card else { + unreachable!() + }; + card.patches[0].diff.clone() + } + + const HUNK: &str = "@@ -1,1 +1,1 @@\n-old\n+new\n"; + + #[test] + fn envelope_passes_a_plain_diff_through_untouched() { + let mut card = patch_card(file_patch("src/work.ts", HUNK)); + + PatchNormalizer::normalize_hunk_headers(&mut card).unwrap(); + + assert_eq!(normalized_diff(card), HUNK); + } + + #[test] + fn envelope_strips_markdown_fences_around_the_diff() { + for wrapped in [ + format!("```\n{HUNK}```\n"), + format!("```diff\n{HUNK}```"), + format!("```diff\n{HUNK}```\n\n"), + ] { + let mut card = patch_card(file_patch("src/work.ts", &wrapped)); + + PatchNormalizer::normalize_hunk_headers(&mut card).unwrap(); + + assert_eq!(normalized_diff(card), HUNK, "input: {wrapped:?}"); + } + } + + #[test] + fn envelope_strips_a_git_header_block_naming_the_target_file() { + let wrapped = format!( + "diff --git a/src/work.ts b/src/work.ts\nindex 1111111..2222222 100644\n--- a/src/work.ts\n+++ b/src/work.ts\n{HUNK}" + ); + let mut card = patch_card(file_patch("src/work.ts", &wrapped)); + + PatchNormalizer::normalize_hunk_headers(&mut card).unwrap(); + + assert_eq!(normalized_diff(card), HUNK); + } + + #[test] + fn envelope_strips_a_new_file_header_with_dev_null_source() { + let wrapped = format!("--- /dev/null\n+++ b/src/work.ts\n{HUNK}"); + let mut card = patch_card(file_patch("src/work.ts", &wrapped)); + + PatchNormalizer::normalize_hunk_headers(&mut card).unwrap(); + + assert_eq!(normalized_diff(card), HUNK); + } + + #[test] + fn envelope_strips_fences_and_header_together() { + let wrapped = format!("```diff\n--- a/src/work.ts\n+++ b/src/work.ts\n{HUNK}```\n"); + let mut card = patch_card(file_patch("src/work.ts", &wrapped)); + + PatchNormalizer::normalize_hunk_headers(&mut card).unwrap(); + + assert_eq!(normalized_diff(card), HUNK); + } + + #[test] + fn envelope_rejects_a_header_naming_another_file() { + let wrapped = format!("--- a/src/other.ts\n+++ b/src/other.ts\n{HUNK}"); + let mut card = patch_card(file_patch("src/work.ts", &wrapped)); + + let error = PatchNormalizer::normalize_hunk_headers(&mut card).unwrap_err(); + + assert_eq!( + crate::violation_class(&error), + Some(ViolationClass::WrongFile) + ); + } + + #[test] + fn envelope_rejects_a_rename_header() { + let wrapped = format!("rename from src/old.ts\nrename to src/work.ts\n{HUNK}"); + let mut card = patch_card(file_patch("src/work.ts", &wrapped)); + + let error = PatchNormalizer::normalize_hunk_headers(&mut card).unwrap_err(); + + assert_eq!( + crate::violation_class(&error), + Some(ViolationClass::MalformedDiff) + ); + } + + #[test] + fn envelope_rejects_prose_and_a_fenced_non_diff() { + let prose = format!("Here is the fix:\n```diff\n{HUNK}```\n"); + let mut card = patch_card(file_patch("src/work.ts", &prose)); + let error = PatchNormalizer::normalize_hunk_headers(&mut card).unwrap_err(); + assert_eq!( + crate::violation_class(&error), + Some(ViolationClass::MalformedDiff) + ); + + let mut card = patch_card(file_patch("src/work.ts", "```\nnot a diff\n```\n")); + let error = PatchNormalizer::normalize_hunk_headers(&mut card).unwrap_err(); + assert_eq!( + crate::violation_class(&error), + Some(ViolationClass::MalformedDiff) + ); + } + + #[test] + fn envelope_rejects_unmatched_markdown_fences() { + for wrapped in [format!("```diff\n{HUNK}"), format!("{HUNK}```\n")] { + let mut card = patch_card(file_patch("src/work.ts", &wrapped)); + + let error = PatchNormalizer::normalize_hunk_headers(&mut card).unwrap_err(); + + assert_eq!( + crate::violation_class(&error), + Some(ViolationClass::MalformedDiff), + "input: {wrapped:?}" + ); + assert!(error.to_string().contains("unmatched")); + } + } + + #[test] + fn normalizes_a_crlf_diff_against_an_lf_buffer() { + let mut card = patch_card(file_patch( + "src/work.ts", + "@@ -1,1 +1,1 @@\r\n-old\r\n+new\r\n", + )); + let context = context("src/work.ts", "old"); + + PatchNormalizer::normalize_card(&mut card, &context).unwrap(); + PatchValidator::validate_card_against_context(&card, &context).unwrap(); + + let diff = normalized_diff(card); + assert_eq!(diff, HUNK); + assert!(!diff.contains('\r')); + } + + #[test] + fn crlf_diff_with_wrong_context_still_fails() { + let mut card = patch_card(file_patch( + "src/work.ts", + "@@ -1,1 +1,1 @@\r\n-stale\r\n+new\r\n", + )); + let context = context("src/work.ts", "current"); + + let error = PatchNormalizer::normalize_card(&mut card, &context).unwrap_err(); + + assert!(error.to_string().contains("not found")); + } + + #[test] + fn normalizes_current_dir_and_git_prefixes_on_the_patch_path() { + for prefixed in ["./src/work.ts", "a/src/work.ts", "b/src/work.ts"] { + let mut card = patch_card(file_patch(prefixed, HUNK)); + let context = context("src/work.ts", "old"); + + PatchNormalizer::normalize_card(&mut card, &context).unwrap(); + + let Card::Patch(card) = card else { + unreachable!() + }; + assert_eq!( + card.patches[0].file, + PathBuf::from("src/work.ts"), + "input: {prefixed}" + ); + } + } + + #[test] + fn keeps_a_prefixed_path_that_does_not_name_the_target() { + // `a/` could be a real directory; without a match against the accepted + // location the prefix is kept and the mismatch surfaces downstream. + let mut card = patch_card(file_patch("a/src/other.ts", HUNK)); + let context = context("src/work.ts", "old"); + + PatchNormalizer::normalize_card(&mut card, &context).unwrap(); + + let Card::Patch(card) = card else { + unreachable!() + }; + assert_eq!(card.patches[0].file, PathBuf::from("a/src/other.ts")); + } + + #[test] + fn keeps_a_prefixed_path_when_the_target_really_lives_under_it() { + let mut card = patch_card(file_patch("a/src/work.ts", HUNK)); + let context = context("a/src/work.ts", "old"); + + PatchNormalizer::normalize_card(&mut card, &context).unwrap(); + + let Card::Patch(card) = card else { + unreachable!() + }; + assert_eq!(card.patches[0].file, PathBuf::from("a/src/work.ts")); + } + #[test] fn warns_about_an_incomplete_local_rename() { let mut card = Card::Patch(loopbiotic_protocol::PatchCard { diff --git a/rust/crates/loopbiotic_patch/src/violation.rs b/rust/crates/loopbiotic_patch/src/violation.rs new file mode 100644 index 0000000..ad69954 --- /dev/null +++ b/rust/crates/loopbiotic_patch/src/violation.rs @@ -0,0 +1,63 @@ +//! A typed wrapper attaching a [`ViolationClass`] to a contract error at its +//! construction site, so the retry loop can report which model mistake cost +//! the attempt without parsing error strings. + +use loopbiotic_protocol::ViolationClass; + +#[derive(Clone, Debug)] +pub struct ContractViolation { + pub class: ViolationClass, + pub message: String, +} + +impl std::fmt::Display for ContractViolation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ContractViolation {} + +/// Builds a classified contract error. Interchangeable with `anyhow!` at the +/// construction site; the class travels inside the error and is recovered +/// with [`violation_class`]. +pub fn violation(class: ViolationClass, message: impl Into) -> anyhow::Error { + ContractViolation { + class, + message: message.into(), + } + .into() +} + +/// Recovers the class from an error built with [`violation`], looking through +/// any `context(...)` layers added while the error propagated. +pub fn violation_class(error: &anyhow::Error) -> Option { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .map(|violation| violation.class) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn class_survives_added_context() { + let error = violation(ViolationClass::ContextMismatch, "context was not found") + .context("src/work.ts"); + + assert_eq!(format!("{error:#}"), "src/work.ts: context was not found"); + assert_eq!( + violation_class(&error), + Some(ViolationClass::ContextMismatch) + ); + } + + #[test] + fn unclassified_errors_have_no_class() { + let error = anyhow::anyhow!("some plain failure"); + + assert_eq!(violation_class(&error), None); + } +} diff --git a/rust/crates/loopbiotic_protocol/src/rpc.rs b/rust/crates/loopbiotic_protocol/src/rpc.rs index 735da7b..a4c2999 100644 --- a/rust/crates/loopbiotic_protocol/src/rpc.rs +++ b/rust/crates/loopbiotic_protocol/src/rpc.rs @@ -150,12 +150,45 @@ pub struct AgentAttempt { pub token_usage: TokenUsage, #[serde(skip_serializing_if = "Option::is_none")] pub detail: Option, + /// Why the attempt violated the card contract, as a closed telemetry + /// class. Present on contract-retry/rejected attempts; never carries + /// buffer or card content, so trace redaction passes it through. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub violation_class: Option, #[serde(skip_serializing_if = "Option::is_none")] pub candidate_card: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub activities: Vec, } +/// A closed classification of contract violations, used to aggregate which +/// model mistakes still cost a retry turn. One class per distinct error +/// construction site; `Other` only covers errors nothing has classified yet. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ViolationClass { + /// Context/remove lines were not found (or ambiguous) in the buffer. + ContextMismatch, + /// `@@` header coordinates or counts are inconsistent with the hunk body. + HunkHeaderMismatch, + /// The diff text is not a parseable unified diff. + MalformedDiff, + /// The patch targets a different file than the accepted source location. + WrongFile, + /// The card exceeds the file/hunk/changed-line limits. + MultiHunk, + /// A required card field is empty or structurally invalid. + MissingField, + /// The card kind is not allowed in the current session state. + KindMismatch, + /// The card repeats an observation or an already accepted patch step. + DuplicateStep, + /// A goal batch could not be validated coherently across its files. + IncoherentBatch, + /// A violation no construction site has classified. + Other, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct GoalProgress { pub statement: String, @@ -216,4 +249,41 @@ mod tests { assert!(json.get("error").is_none()); } + + #[test] + fn attempt_serializes_violation_class_as_snake_case() { + let attempt = AgentAttempt { + number: 2, + backend: "mock".into(), + outcome: "contract_retry".into(), + violation_class: Some(ViolationClass::ContextMismatch), + ..AgentAttempt::default() + }; + let json = serde_json::to_value(&attempt).unwrap(); + + assert_eq!(json["violation_class"], json!("context_mismatch")); + + let parsed: AgentAttempt = serde_json::from_value(json).unwrap(); + assert_eq!( + parsed.violation_class, + Some(ViolationClass::ContextMismatch) + ); + } + + #[test] + fn attempt_without_violation_class_omits_the_field_and_still_parses() { + let attempt = AgentAttempt { + number: 1, + backend: "mock".into(), + outcome: "accepted".into(), + ..AgentAttempt::default() + }; + let json = serde_json::to_value(&attempt).unwrap(); + + assert!(json.get("violation_class").is_none()); + + // Older traces without the field must keep deserializing. + let parsed: AgentAttempt = serde_json::from_value(json).unwrap(); + assert_eq!(parsed.violation_class, None); + } } diff --git a/tests/lua/test_safety.lua b/tests/lua/test_safety.lua index 2111e82..58ed9b8 100644 --- a/tests/lua/test_safety.lua +++ b/tests/lua/test_safety.lua @@ -67,6 +67,20 @@ return function(t) t.eq(tag, "ok") end) + t.test("attempt logs keep violation classes while redacting contract content", function() + local sanitized = require("loopbiotic.log").sanitize({ + outcome = "contract_retry", + violation_class = "context_mismatch", + detail = "private source line", + candidate_card = { explanation = "private patch" }, + }) + + t.eq(sanitized.outcome, "contract_retry", "outcome") + t.eq(sanitized.violation_class, "context_mismatch", "violation class") + t.eq(sanitized.detail.redacted, true, "detail redacted") + t.eq(sanitized.candidate_card.redacted, true, "candidate redacted") + end) + t.test("repeated_error escalates only on identical consecutive messages", function() t.eq(session.repeated_error(nil, "boom"), false, "first error") t.eq(session.repeated_error("boom", "boom"), true, "same error twice") From e80c53f7687a3299294219c22571001497093294 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 18:19:56 +0200 Subject: [PATCH 32/37] docs: record cache and contract hardening --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf1fb3..b32c08b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ The project follows [Semantic Versioning](https://semver.org/). and only the expected binary is extracted from the release archive. - Retry exhaustion and goal-batch shape mismatches in the session harness degrade to error cards instead of panicking the daemon. +- Queued goal slices that no longer apply after the buffer changes now offer + a one-keypress retry that regenerates only the stale file slice. +- Mechanical model diff wrappers are normalized before validation: CRLF line + endings, markdown fences, matching git headers, and unambiguous `./`, `a/`, + or `b/` path prefixes. Prose, rename/copy metadata, unmatched fences, and + headers naming another file are rejected instead of being silently dropped. - Push CI runs again: the workflow triggered on a nonexistent `main` branch. ### Changed @@ -32,6 +38,9 @@ The project follows [Semantic Versioning](https://semver.org/). returns a structured error (`-32001`) on mismatch instead of failing later with cryptic errors. - The project context scan no longer blocks async worker threads. +- Backend prompts put static contracts and append-only session history before + volatile action and editor context data, preserving the longest reusable + provider-cache prefix across turns and sliced goal continuations. - Internal restructuring: `engine.rs`, `codex_app.rs`, and the context crate are split into focused modules; duplicated backend and Lua helpers are shared; Lua state reset is defined next to the state it resets. @@ -69,6 +78,10 @@ The project follows [Semantic Versioning](https://semver.org/). unit tests for the patch engine and session state, `loopbioticd` JSON-RPC integration tests, session state-machine transition tests, and a real daemon round-trip smoke test. +- Agent-attempt telemetry includes a closed `violation_class` for contract + retries and rejected cards, allowing context mismatches, malformed diffs, + wrong files, missing fields, kind mismatches, duplicate steps, and + incoherent goal batches to be aggregated without logging patch content. ## [0.3.2] - 2026-07-14 From cba8881b46eed73f66a2106cf4eefe55b7e5d0a4 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 19:03:25 +0200 Subject: [PATCH 33/37] test: add real Codex proposal latency gate --- rust/crates/loopbioticd/tests/rpc.rs | 150 ++++++++++++++++++++++++--- 1 file changed, 138 insertions(+), 12 deletions(-) diff --git a/rust/crates/loopbioticd/tests/rpc.rs b/rust/crates/loopbioticd/tests/rpc.rs index 2e239c4..e253cdc 100644 --- a/rust/crates/loopbioticd/tests/rpc.rs +++ b/rust/crates/loopbioticd/tests/rpc.rs @@ -4,7 +4,7 @@ use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; -use std::time::Duration; +use std::time::{Duration, Instant}; use loopbiotic_protocol::PROTOCOL_VERSION; use serde_json::{Value, json}; @@ -12,6 +12,7 @@ use serde_json::{Value, json}; /// Generous per-message deadline so slow CI cannot flake, while a hung daemon /// still fails the test instead of blocking forever. const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); +const REAL_CODEX_RESPONSE_BUDGET: Duration = Duration::from_secs(20); struct Daemon { child: Child, @@ -21,14 +22,36 @@ struct Daemon { impl Daemon { fn spawn() -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_loopbioticd")) + let mut command = Command::new(env!("CARGO_BIN_EXE_loopbioticd")); + command .arg("--stdio") .env("LOOPBIOTIC_BACKEND", "mock") .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn loopbioticd"); + .stderr(Stdio::null()); + Self::spawn_command(command) + } + + fn spawn_codex() -> Self { + let model = + std::env::var("LOOPBIOTIC_REAL_CODEX_MODEL").unwrap_or_else(|_| "gpt-5.6-sol".into()); + let mut command = Command::new(env!("CARGO_BIN_EXE_loopbioticd")); + command + .arg("--stdio") + .env("LOOPBIOTIC_BACKEND", "codex_app") + .env("LOOPBIOTIC_CODEX_COMMAND", "codex") + .env("LOOPBIOTIC_CODEX_ARGS_JSON", r#"["app-server","--stdio"]"#) + .env("LOOPBIOTIC_CODEX_MODEL", model) + .env("LOOPBIOTIC_CODEX_EFFORT", "low") + .env("LOOPBIOTIC_TURN_TIMEOUT_SECS", "120") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + Self::spawn_command(command) + } + + fn spawn_command(mut command: Command) -> Self { + let mut child = command.spawn().expect("spawn loopbioticd"); let stdin = child.stdin.take().expect("child stdin"); let stdout = child.stdout.take().expect("child stdout"); @@ -72,11 +95,34 @@ impl Daemon { self.response_for(id) } + fn timed_request(&mut self, id: &str, method: &str, params: Value) -> (Value, Duration) { + let started = Instant::now(); + self.send(&json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + })); + let response = self.response_for(id); + + (response, started.elapsed()) + } + /// Reads messages until the response with the given id arrives, skipping /// notifications (e.g. agent/progress) and daemon-initiated requests. fn response_for(&mut self, id: &str) -> Value { + self.response_for_with_timeout(id, RESPONSE_TIMEOUT) + } + + fn response_for_with_timeout(&mut self, id: &str, timeout: Duration) -> Value { + let started = Instant::now(); loop { - let message = self.next_message(); + let remaining = timeout.saturating_sub(started.elapsed()); + assert!( + !remaining.is_zero(), + "timed out after {timeout:?} waiting for response {id}" + ); + let message = self.next_message_with_timeout(remaining); if message.get("method").is_some() { continue; } @@ -90,9 +136,15 @@ impl Daemon { } fn next_message(&mut self) -> Value { - match self.lines.recv_timeout(RESPONSE_TIMEOUT) { + self.next_message_with_timeout(RESPONSE_TIMEOUT) + } + + fn next_message_with_timeout(&mut self, timeout: Duration) -> Value { + match self.lines.recv_timeout(timeout) { Ok(line) => serde_json::from_str(&line).expect("daemon wrote invalid JSON"), - Err(RecvTimeoutError::Timeout) => panic!("timed out waiting for daemon output"), + Err(RecvTimeoutError::Timeout) => { + panic!("timed out after {timeout:?} waiting for daemon output") + } Err(RecvTimeoutError::Disconnected) => { panic!("daemon exited before writing a response") } @@ -107,21 +159,34 @@ impl Drop for Daemon { } } -fn start_session_params() -> Value { +fn test_cwd() -> std::path::PathBuf { // An empty, dedicated cwd keeps the context indexer away from real // machine state; "investigate" mode asks the backend for a lead card // instead of entering the goal loop (whose patches need a live editor // to answer editor/read_file validation requests). let cwd = std::env::temp_dir().join(format!("loopbioticd-rpc-test-{}", std::process::id())); std::fs::create_dir_all(&cwd).expect("create test cwd"); + cwd +} + +fn start_session_params() -> Value { + start_session_params_with(test_cwd(), "payload is empty", "investigate", "placeholder") +} + +fn start_session_params_with( + cwd: std::path::PathBuf, + prompt: &str, + mode: &str, + buffer_text: &str, +) -> Value { json!({ "cwd": cwd, "file": "src/work.ts", "cursor": {"line": 1, "column": 1}, "selection": null, - "prompt": "payload is empty", - "mode": "investigate", - "buffer_text": "placeholder", + "prompt": prompt, + "mode": mode, + "buffer_text": buffer_text, "buffer_start_line": 1, "diagnostics": [], }) @@ -270,6 +335,67 @@ fn backend_warmup_reports_the_backend_identity() { ); } +/// Manual product-behavior gate. It is ignored in CI because it requires an +/// authenticated Codex CLI and spends real tokens: +/// +/// cargo test -p loopbioticd --test rpc \ +/// real_codex_auto_proposal_is_fast_and_non_mutating -- --ignored --nocapture +#[test] +#[ignore = "requires an authenticated real Codex CLI"] +fn real_codex_auto_proposal_is_fast_and_non_mutating() { + let cwd = std::env::temp_dir().join(format!("loopbiotic-real-codex-{}", std::process::id())); + let source = cwd.join("src/work.ts"); + std::fs::create_dir_all(source.parent().expect("source parent")).expect("create fixture"); + std::fs::write( + &source, + "export function displayName(first, last) {\n return `${first} ${last}`;\n}\n", + ) + .expect("write fixture"); + + let mut daemon = Daemon::spawn_codex(); + let init = daemon.request("1", "initialize", json!({})); + assert!(init.get("error").is_none(), "unexpected error: {init}"); + + let (response, elapsed) = daemon.timed_request( + "2", + "session/start", + start_session_params_with( + cwd, + "How would you propose making displayName handle an empty last name?", + "auto", + "export function displayName(first, last) {\n return `${first} ${last}`;\n}\n", + ), + ); + assert!( + response.get("error").is_none(), + "unexpected error: {response}" + ); + let result = &response["result"]; + let kind = result["card"]["kind"].as_str().unwrap_or(""); + let title = result["card"]["title"].as_str().unwrap_or(""); + let message = result["card"]["message"].as_str().unwrap_or(""); + eprintln!( + "real Codex: elapsed={elapsed:?} kind={kind} title={title:?} model={} input={} output={} total={} activities={} message={message:?}", + result["model"].as_str().unwrap_or(""), + result["turn_token_usage"]["input_tokens"], + result["turn_token_usage"]["output_tokens"], + result["turn_token_usage"]["total_tokens"], + result["attempts"][0]["activities"] + .as_array() + .map(Vec::len) + .unwrap_or(0), + ); + + assert!( + elapsed <= REAL_CODEX_RESPONSE_BUDGET, + "proposal took {elapsed:?}; Neovim users must not wait over {REAL_CODEX_RESPONSE_BUDGET:?}" + ); + assert!( + matches!(kind, "hypothesis" | "finding" | "choice"), + "a proposal question must return a useful answer/plan, not {kind}: {title}: {message}" + ); +} + #[test] fn unknown_method_returns_error_response() { let mut daemon = Daemon::spawn(); From f18acd19eac4f2a6b055c0e19d40f6eee8570fa5 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 19:21:59 +0200 Subject: [PATCH 34/37] fix: restore patch review controls --- CHANGELOG.md | 10 +- README.md | 9 +- lua/loopbiotic/diff.lua | 34 ++-- lua/loopbiotic/session.lua | 10 +- lua/loopbiotic/ui.lua | 3 + .../loopbiotic_harness/src/engine/mod.rs | 62 ++++--- .../loopbiotic_harness/src/engine/tests.rs | 106 ++++++------ rust/crates/loopbioticd/tests/rpc.rs | 152 +++++++++++++++++- scripts/headless-smoke.lua | 17 ++ tests/lua/test_session.lua | 20 +++ 10 files changed, 331 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b32c08b..d6238ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ The project follows [Semantic Versioning](https://semver.org/). degrade to error cards instead of panicking the daemon. - Queued goal slices that no longer apply after the buffer changes now offer a one-keypress retry that regenerates only the stale file slice. +- Rejecting a draft now stops locally with explicit Retry/Edit/Stop actions + instead of immediately spending another model turn on a replacement. +- Resuming an already-visible action card now moves focus into its window, + and patch action cards register every configured shortcut they display + (accept, reject, retry, why, and go-to) in addition to their single-key aliases. - Mechanical model diff wrappers are normalized before validation: CRLF line endings, markdown fences, matching git headers, and unambiguous `./`, `a/`, or `b/` path prefixes. Prose, rename/copy metadata, unmatched fences, and @@ -52,8 +57,9 @@ The project follows [Semantic Versioning](https://semver.org/). requests the next slice on the same conversation while the current one is under review. Time-to-first-hunk drops from the full-batch generation time to a single slice, accepting usually surfaces the next file instantly, and - a rejected slice reworks one file instead of regenerating the whole batch - (the cancelled speculation's token cost stays visible in session totals). + rejecting cancels the pending continuation and waits for an explicit Retry + before generating a replacement (the cancelled speculation's token cost + stays visible in session totals). Backends that ignore the slice contract keep the legacy complete-batch behavior. New Lua-side additions: backend preflight in the prompt window (failures surface before typing; composed prompts survive failed starts), diff --git a/README.md b/README.md index 5737c58..a8d8c03 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Edit the inline draft Accept, Reject, edit, message, or ask Why Why → explanation → Back to the same pending draft Accept → advance to the next queued hunk without calling the agent -Reject → agent automatically reworks the hunk +Reject → stop locally; Retry explicitly if you want a replacement Next file opens automatically inside the workspace without calling the agent Next editable hunk Repeat until completed-goal summary and local diagnostics check @@ -247,9 +247,10 @@ conversation — accepting the last hunk of a slice usually surfaces the next file instantly. `Accept` advances locally without another model call; accepting the final hunk of the last planned slice closes the goal locally. `Reject` cancels the speculative next slice (its token cost stays visible), -reworks only the rejected file, and resumes the pipeline. Backends that -ignore the slice contract still return one complete batch, which is presented -exactly as before. `Retry`, a file the agent could not inspect, or an +then waits without another model call. An explicit `Retry` reworks the +rejected file and resumes the pipeline. Backends that ignore the slice +contract still return one complete batch, which is presented exactly as +before. `Retry`, a file the agent could not inspect, or an explicit question can return control to the agent. User control is the hunk gate, not a repeated discovery/assess/draft ceremony. Asking `Why` opens a side conversation about the pending hunk and then returns to that exact diff --git a/lua/loopbiotic/diff.lua b/lua/loopbiotic/diff.lua index 69be89a..8878cc7 100644 --- a/lua/loopbiotic/diff.lua +++ b/lua/loopbiotic/diff.lua @@ -12,6 +12,16 @@ local util = require("loopbiotic.util") local M = {} local namespace = vim.api.nvim_create_namespace("loopbiotic-patch") +local function bind(buf, keys, callback) + local seen = {} + for _, key in ipairs(keys) do + if key and key ~= "" and not seen[key] then + seen[key] = true + vim.keymap.set("n", key, callback, { buffer = buf, nowait = true, silent = true }) + end + end +end + function M.show(card, opts) opts = opts or {} local patch = (card.patches or {})[1] @@ -114,7 +124,7 @@ end -- Retrying redrafts the current goal slice against the live buffer, which is -- cheap, so it comes first: recovery is one keypress. There is no "skip this -- hunk" choice because no skip path exists — patch cards carry a single hunk --- and reject reworks the card rather than advancing past it. +-- and rejecting a draft stops at an explicit retry/edit/stop decision. ---@param kind "malformed"|"drift" parse failure vs. stale buffer context ---@param actions (string|table)[]|nil the card's available actions ---@return { reason: string, choices: { label: string, action: "retry"|"cancel" }[] }|nil plan nil when the card cannot retry @@ -272,16 +282,15 @@ function M.controls(card, opts) end end - vim.keymap.set("n", "a", M.accept, { buffer = buf, nowait = true, silent = true }) - vim.keymap.set("n", "q", M.reject, { buffer = buf, nowait = true, silent = true }) - vim.keymap.set("n", "r", M.retry, { buffer = buf, nowait = true, silent = true }) - vim.keymap.set("n", "w", function() + bind(buf, { "a", keys.draft_accept }, M.accept) + bind(buf, { "q", keys.draft_reject }, M.reject) + bind(buf, { "r", keys.draft_retry }, M.retry) + bind(buf, { "w", keys.why }, function() require("loopbiotic").action("why") - end, { buffer = buf, nowait = true, silent = true }) - vim.keymap.set("n", "e", function() + end) + bind(buf, { "e", "g", keys.go_to }, function() M.focus_change() - end, { buffer = buf, nowait = true, silent = true }) - vim.keymap.set("n", "g", M.focus_change, { buffer = buf, nowait = true, silent = true }) + end) local details_key = keys.details or "z" pcall(vim.keymap.del, "n", details_key, { buffer = buf }) if M.details_available(card) then @@ -466,7 +475,7 @@ end function M.send(accepted, patch_ids, changed_files, error) local session_id = state.session_id - local request_id = thinking.start(accepted and "Continuing" or "Reworking", session_id) + local request_id = thinking.start(accepted and "Continuing" or "Rejecting", session_id) rpc.request("patch/apply_result", { session_id = session_id, @@ -494,7 +503,10 @@ function M.send(accepted, patch_ids, changed_files, error) end -- Patch results historically never updated state.backend_model. - session.apply_turn_result(message.result, { update_model = false }) + session.apply_turn_result(message.result, { + update_model = false, + track_backend_error = accepted, + }) end) end diff --git a/lua/loopbiotic/session.lua b/lua/loopbiotic/session.lua index 15256cf..b337b2d 100644 --- a/lua/loopbiotic/session.lua +++ b/lua/loopbiotic/session.lua @@ -64,7 +64,8 @@ end -- Call-site-specific handling (thinking guards, stale-session checks, -- session_id adoption) stays at the call sites. ---@param result table backend turn result ----@param opts? { update_model?: boolean } update_model=false keeps state.backend_model untouched +---@param opts? { update_model?: boolean, track_backend_error?: boolean } +--- update_model=false keeps state.backend_model untouched; track_backend_error=false marks a local result function M.apply_turn_result(result, opts) opts = opts or {} @@ -77,7 +78,12 @@ function M.apply_turn_result(result, opts) log.event("context_optimization", result.context_report or {}) log.event("agent_attempts", result.attempts or {}) state.goal = result.goal or state.goal - track_backend_errors(result.card) + if opts.track_backend_error == false then + state.last_backend_error = nil + state.backend_preflight_error = nil + else + track_backend_errors(result.card) + end require("loopbiotic.card").show(result.card) end diff --git a/lua/loopbiotic/ui.lua b/lua/loopbiotic/ui.lua index 18066f8..21f8f53 100644 --- a/lua/loopbiotic/ui.lua +++ b/lua/loopbiotic/ui.lua @@ -82,6 +82,9 @@ function M.render(buf, win, lines, opts) if win and vim.api.nvim_win_is_valid(win) then M.resize(win, #lines, opts) vim.wo[win].winhighlight = opts.winhighlight or "NormalFloat:LoopbioticNormal,FloatBorder:LoopbioticBorder" + if opts.enter == true then + M.focus(win) + end return buf, win end diff --git a/rust/crates/loopbiotic_harness/src/engine/mod.rs b/rust/crates/loopbiotic_harness/src/engine/mod.rs index 41cdceb..a618690 100644 --- a/rust/crates/loopbiotic_harness/src/engine/mod.rs +++ b/rust/crates/loopbiotic_harness/src/engine/mod.rs @@ -19,9 +19,9 @@ use loopbiotic_backends::{ use loopbiotic_context::ContextOptimizer; use loopbiotic_patch::PatchCoherence; use loopbiotic_protocol::{ - Action, ActionResult, Card, CardKind, ContextBundle, ContextPolicy, MAX_GOAL_CHANGED_LINES, - MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, Mode, PatchApplyResult, StartSessionParams, - StartSessionResult, SummaryCard, TokenUsage, + Action, ActionResult, Card, CardKind, ContextBundle, ContextPolicy, ErrorCard, + MAX_GOAL_CHANGED_LINES, MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, Mode, PatchApplyResult, + StartSessionParams, StartSessionResult, SummaryCard, TokenUsage, }; use crate::session::Session; @@ -389,9 +389,9 @@ impl Engine { self.sessions.insert(session_id.clone(), session); if output.is_ok() { - // Covers both the next slice arriving (consumed speculation or a - // real turn) and the rework after a reject: once the new slice is - // under review, speculate on its continuation. + // Once an accepted result surfaces the next slice, speculate on + // its continuation. Rejections clear goal_slice_continues, so + // they stop here until the user explicitly retries. self.schedule_goal_continuation(&session_id).await; } @@ -503,25 +503,43 @@ impl Engine { session.rejected_patches.extend(result.patch_ids.clone()); session.pending_patch_cards.clear(); - session.state = SessionState::PatchShown; - // The rejected slice is being reworked, so the continuation speculated - // from it is stale; its usage still folds into the session totals. + session.goal_slice_continues = false; + session.next_step = None; + // Rejecting is a local review decision, not permission to spend + // another model turn. Drop the stale continuation and leave an + // explicit Retry action if the user wants a replacement draft. self.cancel_goal_continuation(session).await; - if session.continuous_goal { - return self - .goal_turn_taken( - &session_id, - session, - BackendAction::User(Action::Retry), - progress, - None, - ) - .await; - } + session.state = if session.continuous_goal { + SessionState::GoalLoopFailed + } else { + SessionState::PatchFailed + }; + let detail = result + .error + .filter(|error| !error.trim().is_empty()) + .map(|error| format!(" No changes were applied: {error}")) + .unwrap_or_else(|| " No changes were applied.".into()); + let card = Card::Error(ErrorCard { + id: session.next_card_id("rejected"), + title: "Draft rejected".into(), + message: format!( + "The draft was rejected.{detail} Retry only if you want the agent to generate a replacement." + ), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }); + session.cards.push(card.clone()); - self.action_taken(&session_id, session, Action::Retry, progress, None) - .await + Ok(ActionResult { + session_id, + card, + goal: goal_progress(session), + token_usage: session.token_usage.clone(), + turn_token_usage: TokenUsage::default(), + context_report: session.context.report.clone(), + model: None, + attempts: vec![], + }) } async fn goal_turn_taken( diff --git a/rust/crates/loopbiotic_harness/src/engine/tests.rs b/rust/crates/loopbiotic_harness/src/engine/tests.rs index ede2146..63e2156 100644 --- a/rust/crates/loopbiotic_harness/src/engine/tests.rs +++ b/rust/crates/loopbiotic_harness/src/engine/tests.rs @@ -222,7 +222,7 @@ async fn continuous_goal_reviews_a_multi_file_batch_without_more_model_turns() { } #[tokio::test(flavor = "multi_thread")] -async fn continuous_goal_reworks_a_rejected_hunk_without_leaving_the_loop() { +async fn continuous_goal_reject_stops_without_another_model_turn() { let backend = Arc::new(MockBackend); let mut engine = Engine::new(backend); let mut goal = params(); @@ -241,15 +241,23 @@ async fn continuous_goal_reworks_a_rejected_hunk_without_leaving_the_loop() { context: editor_context("placeholder"), }; - let reworked = engine.apply_result(result).await.unwrap(); + let rejected = engine.apply_result(result).await.unwrap(); - assert!(matches!(reworked.card, Card::Patch(_))); - assert!(reworked.turn_token_usage.total_tokens > 0); - assert!(reworked.goal.completed_steps.is_empty()); + let Card::Error(card) = &rejected.card else { + panic!("expected a local rejection card, got {:?}", rejected.card); + }; + assert_eq!(card.title, "Draft rejected"); + assert!(card.actions.contains(&Action::Retry)); + assert_eq!(rejected.turn_token_usage, TokenUsage::default()); + assert!(rejected.goal.completed_steps.is_empty()); assert_eq!( - reworked.goal.status, + rejected.goal.status, loopbiotic_protocol::GoalStatus::Active ); + assert_eq!( + engine.get(&rejected.session_id).unwrap().state, + SessionState::GoalLoopFailed + ); } #[tokio::test(flavor = "multi_thread")] @@ -313,7 +321,7 @@ async fn rejects_apply_result_for_another_patch_card() { } #[tokio::test(flavor = "multi_thread")] -async fn rejected_apply_returns_reworked_patch_without_summary() { +async fn rejected_apply_waits_for_explicit_retry() { let backend = Arc::new(MockBackend); let mut engine = Engine::new(backend); let start = engine.start(params()).await.unwrap(); @@ -328,13 +336,24 @@ async fn rejected_apply_returns_reworked_patch_without_summary() { context: editor_context("placeholder"), }; - let reworked = engine.apply_result(result).await.unwrap(); + let rejected = engine.apply_result(result).await.unwrap(); - assert!(matches!(reworked.card, Card::Patch(_))); + let Card::Error(card) = &rejected.card else { + panic!("expected a local rejection card, got {:?}", rejected.card); + }; + assert!(card.message.contains("patch context is ambiguous")); + assert_eq!(rejected.turn_token_usage, TokenUsage::default()); assert_eq!( engine.get(&start.session_id).unwrap().state, - SessionState::PatchShown + SessionState::PatchFailed ); + + let retried = engine + .action(&start.session_id, Action::Retry) + .await + .unwrap(); + assert!(matches!(retried.card, Card::Patch(_))); + assert!(retried.turn_token_usage.total_tokens > 0); } #[tokio::test(flavor = "multi_thread")] @@ -735,7 +754,7 @@ async fn settle_continuation(engine: &Engine, session_id: &str) { } #[tokio::test(flavor = "multi_thread")] -async fn rejected_slice_cancels_speculation_folds_its_usage_and_respeculates() { +async fn rejected_slice_cancels_speculation_and_waits_for_explicit_retry() { let backend = Arc::new(CountingBackend::default()); let mut engine = Engine::new(backend.clone()); engine.set_source_context_provider(sliced_goal_provider()); @@ -750,7 +769,7 @@ async fn rejected_slice_cancels_speculation_folds_its_usage_and_respeculates() { // Let the speculation finish so cancelling it folds usage immediately. settle_continuation(&engine, &start.session_id).await; - let reworked = engine + let rejected = engine .apply_result(PatchApplyResult { session_id: start.session_id.clone(), card_id: start.card.id().into(), @@ -763,56 +782,43 @@ async fn rejected_slice_cancels_speculation_folds_its_usage_and_respeculates() { .await .unwrap(); - let Card::Patch(rework) = &reworked.card else { - panic!("expected a reworked slice, got {:?}", reworked.card); + let Card::Error(card) = &rejected.card else { + panic!("expected a local rejection card, got {:?}", rejected.card); }; - assert_eq!(rework.patches[0].file, PathBuf::from("src/work.ts")); - assert!(rework.explanation.starts_with("Rework:")); + assert_eq!(card.title, "Draft rejected"); + assert_eq!(rejected.turn_token_usage, TokenUsage::default()); assert!( - reworked.token_usage.total_tokens - > usage_after_start + reworked.turn_token_usage.total_tokens, + rejected.token_usage.total_tokens > usage_after_start, "the cancelled speculation's usage must fold into the session totals" ); assert!( - engine.continuations.contains_key(&start.session_id), - "a validated rework must re-speculate its continuation" + !engine.continuations.contains_key(&start.session_id), + "rejecting must leave no continuation in flight" ); - // The rework keeps the plan, so accepting it consumes a speculated slice - // for the next planned file. - let second = engine - .apply_result(accept( - &reworked.session_id, - &reworked.card, - "PAYLOAD = PAYLOAD OR {}", - )) + let calls = backend.calls.lock().unwrap().clone(); + assert_eq!(calls.len(), 2, "reject triggered a backend turn: {calls:?}"); + assert!(calls[0].starts_with("progress:Start")); + assert!(calls[1].starts_with("plain:User(Next)")); + + let redrafted = engine + .action(&start.session_id, Action::Retry) .await .unwrap(); - let Card::Patch(second_patch) = &second.card else { - panic!("expected the second slice, got {:?}", second.card); + let Card::Patch(redraft) = &redrafted.card else { + panic!( + "expected an explicitly requested redraft, got {:?}", + redrafted.card + ); }; - assert_eq!(second_patch.patches[0].file, PathBuf::from("src/caller.ts")); - - // The consumed re-speculation surfaced the second slice, whose plan still - // continues, so exactly one speculation for the third slice must now be - // in flight; settle it so the call log is complete and deterministic. + assert_eq!(redraft.patches[0].file, PathBuf::from("src/work.ts")); + let calls = backend.calls.lock().unwrap().clone(); assert!( - engine.continuations.contains_key(&second.session_id), - "the second slice under review must speculate the third" + calls + .iter() + .any(|call| call.starts_with("progress:User(Retry)")), + "explicit retry did not trigger a backend turn: {calls:?}" ); - settle_continuation(&engine, &second.session_id).await; - - let calls = backend.calls.lock().unwrap().clone(); - // Start, cancelled speculation, rework, consumed re-speculation, and the - // (exactly one) speculation for the slice now under review. Any engine - // double-schedule or a take() falling back to a real turn would add a - // call and fail this exact count. - assert_eq!(calls.len(), 5, "unexpected backend calls: {calls:?}"); - assert!(calls[0].starts_with("progress:Start")); - assert!(calls[1].starts_with("plain:User(Next)")); - assert!(calls[2].starts_with("progress:User(Retry)")); - assert!(calls[3].starts_with("plain:User(Next)")); - assert!(calls[4].starts_with("plain:User(Next)")); } #[tokio::test(flavor = "multi_thread")] diff --git a/rust/crates/loopbioticd/tests/rpc.rs b/rust/crates/loopbioticd/tests/rpc.rs index e253cdc..ea051c6 100644 --- a/rust/crates/loopbioticd/tests/rpc.rs +++ b/rust/crates/loopbioticd/tests/rpc.rs @@ -35,13 +35,17 @@ impl Daemon { fn spawn_codex() -> Self { let model = std::env::var("LOOPBIOTIC_REAL_CODEX_MODEL").unwrap_or_else(|_| "gpt-5.6-sol".into()); + Self::spawn_codex_model(model) + } + + fn spawn_codex_model(model: impl AsRef) -> Self { let mut command = Command::new(env!("CARGO_BIN_EXE_loopbioticd")); command .arg("--stdio") .env("LOOPBIOTIC_BACKEND", "codex_app") .env("LOOPBIOTIC_CODEX_COMMAND", "codex") .env("LOOPBIOTIC_CODEX_ARGS_JSON", r#"["app-server","--stdio"]"#) - .env("LOOPBIOTIC_CODEX_MODEL", model) + .env("LOOPBIOTIC_CODEX_MODEL", model.as_ref()) .env("LOOPBIOTIC_CODEX_EFFORT", "low") .env("LOOPBIOTIC_TURN_TIMEOUT_SECS", "120") .stdin(Stdio::piped()) @@ -135,6 +139,44 @@ impl Daemon { } } + fn response_for_with_editor_context( + &mut self, + id: &str, + timeout: Duration, + context: &Value, + ) -> Value { + let started = Instant::now(); + loop { + let remaining = timeout.saturating_sub(started.elapsed()); + assert!( + !remaining.is_zero(), + "timed out after {timeout:?} waiting for response {id}" + ); + let message = self.next_message_with_timeout(remaining); + if let Some(method) = message.get("method").and_then(Value::as_str) { + if let Some(request_id) = message.get("id").and_then(Value::as_str) + && matches!(method, "editor/read_file" | "editor/open_location") + { + self.send(&json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "granted": true, + "context": context, + }, + })); + } + continue; + } + assert_eq!( + message.get("id").and_then(Value::as_str), + Some(id), + "expected a response to {id}, got: {message}" + ); + return message; + } + } + fn next_message(&mut self) -> Value { self.next_message_with_timeout(RESPONSE_TIMEOUT) } @@ -396,6 +438,114 @@ fn real_codex_auto_proposal_is_fast_and_non_mutating() { ); } +/// Manual real-backend gate for the review contract. Codex generates the +/// pending patch, but rejecting it must be a local daemon transition: +/// no replacement turn, no turn tokens, and no user-visible wait. +/// +/// cargo test -p loopbioticd --test rpc \ +/// real_codex_patch_reject_is_local -- --ignored --nocapture +#[test] +#[ignore = "requires an authenticated real Codex CLI"] +fn real_codex_patch_reject_is_local() { + let cwd = std::env::temp_dir().join(format!( + "loopbiotic-real-codex-reject-{}", + std::process::id() + )); + let source = cwd.join("src/work.ts"); + let buffer_text = + "export function displayName(first, last) {\n return `${first} ${last}`;\n}\n"; + std::fs::create_dir_all(source.parent().expect("source parent")).expect("create fixture"); + std::fs::write(&source, buffer_text).expect("write fixture"); + + let model = + std::env::var("LOOPBIOTIC_REAL_CODEX_MODEL").unwrap_or_else(|_| "gpt-5.4-mini".into()); + let mut daemon = Daemon::spawn_codex_model(model); + let init = daemon.request("1", "initialize", json!({})); + assert!(init.get("error").is_none(), "unexpected error: {init}"); + + let editor_context = json!({ + "cwd": cwd, + "file": "src/work.ts", + "cursor": {"line": 1, "column": 1}, + "selection": null, + "buffer_text": buffer_text, + "buffer_start_line": 1, + "diagnostics": [], + "hints": [], + "artifacts": [] + }); + daemon.send(&json!({ + "jsonrpc": "2.0", + "id": "2", + "method": "session/start", + "params": start_session_params_with( + cwd.clone(), + "Change displayName to return first when last is empty.", + "fix", + buffer_text, + ), + })); + let patch_response = + daemon.response_for_with_editor_context("2", Duration::from_secs(120), &editor_context); + assert!( + patch_response.get("error").is_none(), + "unexpected patch error: {patch_response}" + ); + let patch_result = &patch_response["result"]; + assert_eq!( + patch_result["card"]["kind"], + json!("patch"), + "real Codex did not return a patch: {patch_result}" + ); + let session_id = patch_result["session_id"] + .as_str() + .expect("session id") + .to_owned(); + let card_id = patch_result["card"]["id"] + .as_str() + .expect("card id") + .to_owned(); + let patch_id = patch_result["card"]["patches"][0]["id"] + .as_str() + .expect("patch id") + .to_owned(); + + let (rejected, elapsed) = daemon.timed_request( + "3", + "patch/apply_result", + json!({ + "session_id": session_id, + "card_id": card_id, + "accepted": false, + "patch_ids": [patch_id], + "changed_files": [], + "error": null, + "context": editor_context + }), + ); + assert!( + rejected.get("error").is_none(), + "unexpected rejection error: {rejected}" + ); + let result = &rejected["result"]; + eprintln!( + "real Codex reject: elapsed={elapsed:?} kind={} title={:?} turn_tokens={} attempts={}", + result["card"]["kind"].as_str().unwrap_or(""), + result["card"]["title"].as_str().unwrap_or(""), + result["turn_token_usage"]["total_tokens"], + result["attempts"].as_array().map(Vec::len).unwrap_or(0), + ); + + assert!( + elapsed < Duration::from_secs(2), + "reject took {elapsed:?}; it must not wait for Codex" + ); + assert_eq!(result["card"]["kind"], json!("error")); + assert_eq!(result["card"]["title"], json!("Draft rejected")); + assert_eq!(result["turn_token_usage"]["total_tokens"], json!(0)); + assert_eq!(result["attempts"], json!([])); +} + #[test] fn unknown_method_returns_error_response() { let mut daemon = Daemon::spawn(); diff --git a/scripts/headless-smoke.lua b/scripts/headless-smoke.lua index 32ddd93..3cb804f 100644 --- a/scripts/headless-smoke.lua +++ b/scripts/headless-smoke.lua @@ -149,6 +149,22 @@ local action_height = action_config.height + (ui.has_border(action_config.border local action_bottom = action_top + action_height - 1 assert(action_bottom < proposal_position.row - 1 or action_top > proposal_position.row + 1) assert(loopbiotic.actions_visible()) +loopbiotic.resume() +assert(vim.api.nvim_get_current_win() == state.card_win) +for _, key in ipairs({ + config.values.keymaps.draft_accept, + config.values.keymaps.draft_reject, + config.values.keymaps.draft_retry, + config.values.keymaps.why, + config.values.keymaps.go_to, +}) do + local mapping = vim.fn.maparg(key, "n", false, true) + assert(mapping.buffer == 1, "missing action-window mapping for " .. key) + assert(type(mapping.callback) == "function", "action-window mapping has no callback for " .. key) +end +local go_to_mapping = vim.fn.maparg(config.values.keymaps.go_to, "n", false, true) +go_to_mapping.callback() +assert(vim.api.nvim_get_current_win() == state.diff_win) loopbiotic.go_to() assert(vim.deep_equal(vim.api.nvim_win_get_cursor(0), { 2, 2 })) loopbiotic.hide() @@ -159,6 +175,7 @@ assert(state.diff_buf == hidden_draft) assert(diff.valid_preview()) loopbiotic.resume() assert(loopbiotic.actions_visible()) +assert(vim.api.nvim_get_current_win() == state.card_win) local proposal_card = state.card state.card = { id = "stopped-card", kind = "summary", title = "Stopped", next_actions = {} } loopbiotic.action("stop") diff --git a/tests/lua/test_session.lua b/tests/lua/test_session.lua index 2cfc275..11ef659 100644 --- a/tests/lua/test_session.lua +++ b/tests/lua/test_session.lua @@ -70,4 +70,24 @@ return function(t) end) state.reset() end) + + t.test("local rejection cards do not count as repeated backend errors", function() + state.reset() + state.last_backend_error = "old backend failure" + state.backend_preflight_error = "old preflight failure" + with_stubbed_show(function() + session.apply_turn_result({ + card = { + id = "rejected", + kind = "error", + title = "Draft rejected", + message = "The draft was rejected.", + actions = { "retry", "edit_prompt", "stop" }, + }, + }, { track_backend_error = false }) + t.eq(state.last_backend_error, nil) + t.eq(state.backend_preflight_error, nil) + end) + state.reset() + end) end From 8bdcfa9795311426e2885a152723bf77750c7317 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 21:03:38 +0200 Subject: [PATCH 35/37] feat: make pairing conversational and interruptible --- CHANGELOG.md | 49 +- README.md | 80 ++- doc/loopbiotic.txt | 37 +- lua/loopbiotic/card.lua | 18 +- lua/loopbiotic/commands.lua | 12 +- lua/loopbiotic/config.lua | 19 +- lua/loopbiotic/init.lua | 45 +- lua/loopbiotic/keymaps.lua | 2 + lua/loopbiotic/session.lua | 3 + lua/loopbiotic/state.lua | 4 +- lua/loopbiotic/version.lua | 2 +- .../loopbiotic_backends/src/claude_app.rs | 49 +- .../loopbiotic_backends/src/codex_app/mod.rs | 221 ++++++-- .../src/codex_app/schema.rs | 52 +- .../src/codex_app/transport.rs | 32 ++ .../crates/loopbiotic_backends/src/generic.rs | 17 +- rust/crates/loopbiotic_backends/src/lib.rs | 30 ++ rust/crates/loopbiotic_backends/src/mock.rs | 14 +- .../loopbiotic_backends/src/stdio_agent.rs | 13 +- .../crates/loopbiotic_backends/src/support.rs | 1 + .../loopbiotic_harness/src/engine/goal.rs | 20 +- .../loopbiotic_harness/src/engine/mod.rs | 390 +++++++++++--- .../loopbiotic_harness/src/engine/prefetch.rs | 159 ++---- .../loopbiotic_harness/src/engine/tests.rs | 510 +++++++++++------- .../loopbiotic_harness/src/engine/turn.rs | 12 +- .../loopbiotic_harness/src/engine/validate.rs | 22 +- rust/crates/loopbiotic_harness/src/session.rs | 24 +- rust/crates/loopbiotic_harness/src/state.rs | 71 ++- rust/crates/loopbiotic_patch/src/validate.rs | 9 +- rust/crates/loopbiotic_protocol/src/agent.rs | 4 +- rust/crates/loopbiotic_protocol/src/card.rs | 34 +- rust/crates/loopbiotic_protocol/src/lib.rs | 2 +- rust/crates/loopbiotic_protocol/src/patch.rs | 3 - rust/crates/loopbiotic_protocol/src/rpc.rs | 2 + rust/crates/loopbioticd/src/main.rs | 470 ++++++++++++++-- rust/crates/loopbioticd/tests/rpc.rs | 346 +++++++++++- scripts/headless-smoke.lua | 4 +- tests/lua/test_interactivity.lua | 113 ++++ 38 files changed, 2216 insertions(+), 679 deletions(-) create mode 100644 tests/lua/test_interactivity.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index d6238ed..e956823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,8 @@ The project follows [Semantic Versioning](https://semver.org/). and only the expected binary is extracted from the release archive. - Retry exhaustion and goal-batch shape mismatches in the session harness degrade to error cards instead of panicking the daemon. -- Queued goal slices that no longer apply after the buffer changes now offer - a one-keypress retry that regenerates only the stale file slice. +- Goal hunks that no longer apply after the buffer changes now offer a + one-keypress retry that regenerates only the stale local step. - Rejecting a draft now stops locally with explicit Retry/Edit/Stop actions instead of immediately spending another model turn on a replacement. - Resuming an already-visible action card now moves focus into its window, @@ -39,6 +39,13 @@ The project follows [Semantic Versioning](https://semver.org/). ### Changed +- Auto sessions are conversational-first. Their first response and normal + replies cannot return patches or completion summaries; persistent goal + execution starts only from the explicit `Goal` action. Sending a message + pauses an active goal and answers conversationally. +- Goal work is limited to one file, one coherent hunk, and 32 changed lines + per turn. Only explicit goals may speculate on the next patch; ordinary + speculation is read-only post-accept conversation. - `initialize` validates the client protocol version when one is supplied and returns a structured error (`-32001`) on mismatch instead of failing later with cryptic errors. @@ -52,34 +59,36 @@ The project follows [Semantic Versioning](https://semver.org/). ### Added -- Goal turns are now sliced: the agent returns exactly one file's patch per - turn plus a plan of the remaining files, and Loopbiotic speculatively - requests the next slice on the same conversation while the current one is - under review. Time-to-first-hunk drops from the full-batch generation time - to a single slice, accepting usually surfaces the next file instantly, and - rejecting cancels the pending continuation and waits for an explicit Retry - before generating a replacement (the cancelled speculation's token cost - stays visible in session totals). - Backends that ignore the slice contract keep the legacy complete-batch - behavior. New Lua-side additions: backend preflight in the prompt window - (failures surface before typing; composed prompts survive failed starts), - repeated-error escalation with actionable guidance, and a client-side - error boundary that preserves the session when a UI callback fails. +- Conversation turns have a 10-second visible-response budget and work turns + a 20-second budget. Slow turns yield a focusable `Working` card, continue in + the background, and can be interrupted through the real Codex + `turn/interrupt` API or by terminating persistent CLI processes. Completion + arrives through `agent/turn_ready`; slow-turn timing is logged locally and + injected once as compact feedback on the next model turn. +- Accepting a non-goal patch automatically surfaces a read-only conversational + next card, with no intermediate “local step applied” summary. That card is + prefetched during review by default; rejecting remains a local decision and + never regenerates code. +- Backend preflight in the prompt window (failures surface before typing; + composed prompts survive failed starts), repeated-error escalation with + actionable guidance, and a client-side error boundary that preserves the + session when a UI callback fails. - The `backend/warmup` handshake now reports an explicit identity: the active backend, the concrete model the next turn will use (configured, or resolved from the backend — the Claude CLI announces it at process start, Ollama always knows it), and the models the backend can enumerate (Ollama's local - tags). Protocol version is now 9. + tags). Protocol version is now 10. - The prompt window title names the active agent and resolved model (never "default"), refreshing as soon as warmup resolves it, and `Ctrl-l` (`keymaps.models`) opens a model picker fed by the backend-enumerated models, an optional per-agent `models` list, and the last reported model. Selections persist per agent exactly like `:LoopbioticModel`. - Identity is phase-aware: the reported model is always the patch-drafting - one, and a differing pinned discovery model (the shipped claude agent uses - `discovery_model = "haiku"`) is shown separately instead of masquerading - as the active model. The claude picker offers the CLI aliases `sonnet`, - `opus`, and `haiku` since the CLI has no model-listing API. + one, and a differing discovery model is shown separately instead of + masquerading as the active model. The shipped Codex agent uses + `gpt-5.4-mini` at low effort for conversation; Claude uses + `discovery_model = "haiku"`. The claude picker offers the CLI aliases + `sonnet`, `opus`, and `haiku` since the CLI has no model-listing API. - Lua tooling (`stylua`, `selene`, LuaLS config) enforced in CI, headless Lua unit tests for the patch engine and session state, `loopbioticd` JSON-RPC integration tests, session state-machine transition tests, and a real diff --git a/README.md b/README.md index a8d8c03..51dea6c 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Implemented capabilities include: - raw, cached, and non-cached session token usage plus a local error log - JSON-RPC over stdio - Rust session harness -- continuous goal state machine with local hunk-by-hunk review +- conversational-first state machine with explicit goals and local hunk review - patch gate - mock backend - generic CLI backend @@ -164,8 +164,9 @@ the next turn will use, e.g. `codex / gpt-5.4-mini`. Without a configured model it shows the model the backend announces during warmup (or reported after the last turn), and `model?` until one is known — it never shows `default`. The title always names the patch-drafting model; when an agent -runs discovery on a different model (the shipped claude agent pins -`discovery_model = "haiku"`), that is shown separately, e.g. +runs discovery on a different model (the shipped Codex agent uses +`gpt-5.4-mini` at low effort; Claude pins `discovery_model = "haiku"`), that +is shown separately, e.g. `claude-fable-5 · discovery haiku`. Press `` (`keymaps.models`) inside the prompt to pick a model from every known candidate: the configured model, the models the backend enumerates (Ollama's local tags; claude offers its @@ -195,17 +196,14 @@ require("loopbiotic").setup({ ```text a Prompt -Persistent agent goal -Agent inspects the project and returns the first file slice plus its plan -One local editable hunk (the next slice is prepared while you review) -Edit the inline draft -Accept, Reject, edit, message, or ask Why -Why → explanation → Back to the same pending draft -Accept → advance to the next queued hunk without calling the agent -Reject → stop locally; Retry explicitly if you want a replacement -Next file opens automatically inside the workspace without calling the agent -Next editable hunk -Repeat until completed-goal summary and local diagnostics check +Conversational answer, finding, or question — never an implicit patch +Follow up or reply back-to-back +Draft → one local editable hunk +Accept → automatic read-only next card, without an intermediate summary +Reject → stop locally; Retry only when explicitly requested +Goal → explicitly authorize a sequence of small reviewed hunks +While a goal hunk is reviewed, only its next small hunk may be prepared +Repeat until one final goal summary and local diagnostics check ``` Cards stay anchored clear of the source line and do not take focus. Use `pg` @@ -216,13 +214,19 @@ and draft explanations stay compact by default; press `z` while the card is focused to expand or collapse their full text (`keymaps.details` changes this key). -For a goal patch, Loopbiotic validates every returned file against its live editor -buffer, queues the complete batch, and opens each location only when its hunk is -ready for review. Navigation and acceptance are local operations and do not -start another model turn. Automatic navigation is restricted to the current -workspace; edits are still inert drafts until the user accepts each hunk. If -the agent could not inspect a required file, `open_location` remains a fallback -that supplies its buffer in a subsequent turn. +Conversational turns have a 10-second interaction deadline and work turns have +a 20-second deadline. Crossing it yields a focusable `Working` card instead of +holding Neovim; the agent continues in the background, and `Cancel` interrupts +the real backend turn. Slow-turn timing is recorded in the local trace and a +compact, content-free instruction is injected into the next turn so the agent +prioritizes an earlier useful response. + +For an explicit goal, each backend turn may return at most one file, one +coherent hunk, and 32 changed lines, plus a plan of the remaining coherent +steps. The next hunk may be prepared while the current one is reviewed. +Accepting normally surfaces it immediately; rejecting cancels it and never +generates a replacement without `Retry`. Automatic navigation stays inside the +workspace, and every edit remains an inert draft until accepted. Loopbiotic moves directly to the evidence for a location-bearing card and to the first non-blank character of the first added line for a draft, including drafts @@ -238,34 +242,22 @@ with `/{kind}` to demand a specific card instead — `/hypothesis`, `/finding`, Unknown words after `/` are treated as normal prompt text, so paths like `/tmp/project` are safe. -The goal and accepted-step count stay visible on cards and editable drafts. In -the default `auto` mode the agent owns the complete process, delivered as file -slices: each goal turn returns exactly one file's complete patch plus a plan -of the remaining files, so no single wait spans the whole change. While a -slice is under review, Loopbiotic already requests the next one on the same -conversation — accepting the last hunk of a slice usually surfaces the next -file instantly. `Accept` advances locally without another model call; -accepting the final hunk of the last planned slice closes the goal locally. -`Reject` cancels the speculative next slice (its token cost stays visible), -then waits without another model call. An explicit `Retry` reworks the -rejected file and resumes the pipeline. Backends that ignore the slice -contract still return one complete batch, which is presented exactly as -before. `Retry`, a file the agent could not inspect, or an -explicit question can return control to the agent. User control is the hunk gate, not a -repeated discovery/assess/draft ceremony. Asking `Why` -opens a side conversation about the pending hunk and then returns to that exact -draft without advancing or replacing it. Explicit -`/{kind}` prompts and investigate/explain/review modes remain available for -one-card workflows. +The goal and accepted-step count stay visible on cards and editable drafts, but +`auto` is conversational: it never starts goal execution or returns a patch on +the first turn. Use `Goal` (`pG` or `:LoopbioticGoal`) to authorize +persistent execution, and send a normal message at any time to pause it and get +a conversational answer. Asking `Why` explains the pending hunk and returns to +that exact draft without advancing or replacing it. When the goal completes, Loopbiotic automatically checks error-level diagnostics in the changed, loaded buffers after a short delay. `Check` repeats the same local operation without spending model tokens, saving buffers, or running shell test commands. -Speculative patch prefetch is off by default because an unused draft still costs -a full model turn. Set `backend.prefetch = "fix"` only when that latency/cost -tradeoff is intentional. +Ordinary speculation is read-only: by default +`backend.prefetch = "read_only"` prepares only the conversational card that +follows an accepted non-goal draft. Set it to `"off"` to disable this. Patch +speculation is allowed only inside an explicitly active goal. Cards show raw, cached, and non-cached turn and session usage against `backend.token_budget` (50,000 raw tokens by default). @@ -374,6 +366,8 @@ The current implementation does not train or run an ML model. :Loopbiotic :LoopbioticReply :LoopbioticFix +:LoopbioticGoal +:LoopbioticCancel :LoopbioticWhy :LoopbioticFollow :LoopbioticOther diff --git a/doc/loopbiotic.txt b/doc/loopbiotic.txt index cc3fec4..d9a72da 100644 --- a/doc/loopbiotic.txt +++ b/doc/loopbiotic.txt @@ -16,18 +16,20 @@ Loopbiotic presents one hypothesis, finding, or editable patch at a time. It is designed for explicit user-controlled pair-programming steps rather than an open-ended chat session. -In the default auto mode, one prompt starts a persistent goal loop. The agent -returns the change one file slice at a time, together with a plan of the -remaining files; while a slice is under review Loopbiotic already requests the -next one on the same conversation, so accepting usually surfaces the next -file without a wait. Every slice is validated against live editor buffers and -presented one hunk at a time. Accepting a hunk advances through the local -queue without a new model turn; rejecting reworks only that file's slice and -cancels the speculative next one. Backends that ignore the slice contract -return one complete batch, presented exactly as before. The loop ends only -after the final planned slice is accepted and local diagnostics are checked. -Use `keymaps.why` (`pw` by default) on a draft to ask why; the answer -keeps that exact hunk pending and offers `Back to draft`. +The default auto mode is conversational. Its first turn returns an answer, +finding, hypothesis, or question, never an implicit patch. Use `Fix` for one +local editable hunk or `Goal` to explicitly authorize a persistent sequence. +Goal turns remain small: one file, one coherent hunk, at most 32 changed +lines, plus a plan of the remaining coherent steps. Only an active goal may +prepare the next patch while the current hunk is reviewed. + +Conversation turns yield after 10 seconds and work turns after 20 seconds. +The focusable `Working` card leaves Neovim usable while the real agent +continues in the background; `Cancel` interrupts that backend turn. Accepting +a non-goal draft automatically shows a read-only conversational next card +without an intermediate summary. Rejecting stays local and never regenerates +code until `Retry`. Use `keymaps.why` (`pw`) on a draft to explain the +same pending hunk and return to it. Navigation stays inside the current workspace. A location-bearing card moves the cursor to its evidence, while a draft moves it to the first non-blank @@ -52,7 +54,8 @@ The prompt window title names the active agent and the concrete model the next turn will use, falling back to the backend-announced model and then `model?` — it never shows `default`. The title always names the patch-drafting model; a differing pinned discovery model (the shipped claude -agent uses `discovery_model = "haiku"`) is shown separately, e.g. +agent uses `discovery_model = "haiku"` and Codex uses `gpt-5.4-mini` at low +effort) is shown separately, e.g. `claude-fable-5 · discovery haiku`. Press `keymaps.models` (`` by default) inside the prompt to pick from the known models: the configured model, the models the backend enumerates (claude offers the CLI aliases @@ -83,6 +86,10 @@ COMMANDS *loopbiotic-commands* :LoopbioticReply Send a message in the active session. *:LoopbioticFix* :LoopbioticFix Request one local editable patch. + *:LoopbioticGoal* +:LoopbioticGoal Explicitly start or continue the persistent goal. + *:LoopbioticCancel* +:LoopbioticCancel Interrupt the current Working turn. *:LoopbioticFollow* :LoopbioticFollow Follow the current observation. *:LoopbioticWhy* @@ -90,9 +97,9 @@ COMMANDS *loopbiotic-commands* *:LoopbioticOther* :LoopbioticOther Request another lead. *:LoopbioticAssess* -:LoopbioticAssess Assess the whole goal and locate one unresolved step. +:LoopbioticAssess Compatibility alias for |:LoopbioticGoal|. *:LoopbioticNext* -:LoopbioticNext Compatibility alias for |:LoopbioticAssess|. +:LoopbioticNext Compatibility alias for |:LoopbioticGoal|. *:LoopbioticStop* :LoopbioticStop Finish the active session. *:LoopbioticHide* diff --git a/lua/loopbiotic/card.lua b/lua/loopbiotic/card.lua index d307e55..db40da2 100644 --- a/lua/loopbiotic/card.lua +++ b/lua/loopbiotic/card.lua @@ -7,7 +7,7 @@ local util = require("loopbiotic.util") ---@class LoopbioticCard a card proposed by the backend ---@field id string ----@field kind string "hypothesis" | "finding" | "patch" | "summary" | "error" | "deny" | "choice" +---@field kind string "hypothesis" | "finding" | "patch" | "working" | "summary" | "error" | "deny" | "choice" ---@field title? string ---@field actions? (string|table)[] available actions; tables carry apply_patch payloads ---@field next_actions? (string|table)[] legacy name for actions @@ -35,6 +35,8 @@ local labels = { why = { "w", "Why", "why" }, resume_draft = { "b", "Back to draft", nil }, fix = { "x", "Draft", "fix" }, + goal = { "G", "Goal", "goal" }, + cancel_turn = { "c", "Cancel", "cancel" }, other_lead = { "n", "Other", "other_lead" }, apply = { "a", "Review", "draft_accept" }, apply_patch = { "a", "Review", "draft_accept" }, @@ -131,6 +133,18 @@ function M.lines(card) end table.insert(lines, "") table.insert(lines, tostring(#(card.patches or {})) .. " file patch pending") + elseif card.kind == "working" then + table.insert(lines, card.message or card.title or "Agent is still working") + table.insert(lines, "") + table.insert(lines, string.format("Phase %s", card.phase or "working")) + table.insert( + lines, + string.format( + "Budget %sms · elapsed %sms", + tonumber(card.deadline_ms) or 0, + tonumber(card.elapsed_ms) or tonumber(card.deadline_ms) or 0 + ) + ) elseif card.kind == "summary" then if M.has_action(card, "next") then table.insert(lines, "Status Local step applied") @@ -197,6 +211,8 @@ function M.goal(lines) table.insert(lines, "State Needs goal assessment") elseif goal.status == "complete" then table.insert(lines, "State Goal complete") + elseif goal.status == "paused" then + table.insert(lines, "State Goal paused") elseif goal.next_step and goal.next_step ~= "" then table.insert(lines, "Now " .. M.short(goal.next_step, 54)) end diff --git a/lua/loopbiotic/commands.lua b/lua/loopbiotic/commands.lua index 2c1a2d1..2a84877 100644 --- a/lua/loopbiotic/commands.lua +++ b/lua/loopbiotic/commands.lua @@ -22,6 +22,14 @@ function M.setup() M.action_or_prompt("fix", "fix") end) + command("LoopbioticGoal", function() + require("loopbiotic").action("goal") + end) + + command("LoopbioticCancel", function() + require("loopbiotic").action("cancel_turn") + end) + command("LoopbioticWhy", function() M.action_or_prompt("why", "explain") end) @@ -35,11 +43,11 @@ function M.setup() end) command("LoopbioticAssess", function() - require("loopbiotic").action("next") + require("loopbiotic").action("goal") end) command("LoopbioticNext", function() - require("loopbiotic").action("next") + require("loopbiotic").action("goal") end) command("LoopbioticStop", function() diff --git a/lua/loopbiotic/config.lua b/lua/loopbiotic/config.lua index 6972728..a33d791 100644 --- a/lua/loopbiotic/config.lua +++ b/lua/loopbiotic/config.lua @@ -5,7 +5,7 @@ local M = {} ---@field args string[] ---@field mode string default prompt mode ("auto", "fix", "explain", ...) ---@field agent string key into LoopbioticConfig.agents ----@field prefetch "off"|"fix" speculative patch prefetch +---@field prefetch "off"|"read_only" read-only post-accept prefetch ---@field token_budget integer ask before another turn past this session total; 0 disables ---@class LoopbioticAgentConfig @@ -16,7 +16,8 @@ local M = {} ---@field model_flag? string ---@field models? string[] extra model-picker candidates for this agent ---@field effort? string codex_app only ----@field discovery_model? string claude_app only +---@field discovery_model? string codex_app/claude_app +---@field discovery_effort? string codex_app only ---@field discovery_thinking? integer claude_app only ---@field host? string ollama only ---@field keep_alive? string ollama only @@ -41,9 +42,9 @@ M.values = { args = {}, mode = "auto", agent = "mock", - -- Speculative prefetch spends a patch turn before the user asks for it. - -- Keep it opt-in with "fix"; "off" never starts speculative model work. - prefetch = "off", + -- Ordinary speculation never drafts code: it prepares only the + -- conversational card shown after an accepted local patch. + prefetch = "read_only", -- Ask before starting another model turn after this session total. -- Set to 0 to disable the guard. token_budget = 50000, @@ -65,6 +66,8 @@ M.values = { kind = "codex_app", command = "codex", effort = "low", + discovery_model = "gpt-5.4-mini", + discovery_effort = "low", models = {}, args = { "app-server", @@ -103,6 +106,8 @@ M.values = { follow = "pf", why = "pw", fix = "px", + goal = "pG", + cancel = "pc", other_lead = "pn", stop = "pq", hide = "ph", @@ -339,7 +344,7 @@ end function M.backend_env() local _, agent = M.agent_config() local env = M.agent_env(agent) - env.LOOPBIOTIC_PREFETCH = M.values.backend.prefetch or "off" + env.LOOPBIOTIC_PREFETCH = M.values.backend.prefetch or "read_only" return env end @@ -372,6 +377,8 @@ function M.agent_env(agent) LOOPBIOTIC_CODEX_ARGS_JSON = vim.json.encode(args), LOOPBIOTIC_CODEX_MODEL = agent.model or "", LOOPBIOTIC_CODEX_EFFORT = agent.effort or "low", + LOOPBIOTIC_CODEX_DISCOVERY_MODEL = agent.discovery_model or "", + LOOPBIOTIC_CODEX_DISCOVERY_EFFORT = agent.discovery_effort or "low", } end diff --git a/lua/loopbiotic/init.lua b/lua/loopbiotic/init.lua index 7ee06e2..98281de 100644 --- a/lua/loopbiotic/init.lua +++ b/lua/loopbiotic/init.lua @@ -15,9 +15,36 @@ local util = require("loopbiotic.util") local M = {} rpc.on("agent/progress", function(progress) + if + state.card + and state.card.kind == "working" + and progress.session_id == state.session_id + and state.card.turn_id ~= state.cancelled_turn_id + then + state.card.phase = progress.phase or state.card.phase + state.card.message = progress.message or state.card.message + card.show(state.card) + return + end thinking.progress(progress) end) +rpc.on("agent/turn_ready", function(params) + if params.session_id ~= state.session_id or params.turn_id == state.cancelled_turn_id then + return + end + if not (state.card and state.card.kind == "working" and state.card.turn_id == params.turn_id) then + return + end + if params.error then + ui.notify(params.error, vim.log.levels.ERROR) + return + end + if params.result then + session.apply_turn_result(params.result) + end +end) + rpc.on_request("editor/read_file", function(params, respond) local file = params.file or "?" if not M.workspace_location(file) then @@ -108,7 +135,7 @@ function M.start(text, mode, source) statement = text, completed_steps = {}, known_observations = {}, - status = "active", + status = "idle", } state.workspace_hints = context.workspace_hints(text, params.cwd, captured.buf) params.hints = context.merge_hints(params.hints, state.workspace_hints) @@ -150,6 +177,10 @@ function M.action(action, opts) return end + if state.card and state.card.kind == "working" then + state.cancelled_turn_id = state.card.turn_id + end + if action == "why" then local diff = require("loopbiotic.diff") if diff.valid_preview() then @@ -306,6 +337,10 @@ function M.reply(text) return end + if state.card and state.card.kind == "working" then + state.cancelled_turn_id = state.card.turn_id + end + status.hide() local session_id = state.session_id @@ -348,7 +383,13 @@ function M.token_budget_exceeded() end function M.confirm_agent_turn(action) - if action == "apply" or action == "open" or action == "resume_draft" or action == "stop" then + if + action == "apply" + or action == "open" + or action == "resume_draft" + or action == "stop" + or action == "cancel_turn" + then return true end diff --git a/lua/loopbiotic/keymaps.lua b/lua/loopbiotic/keymaps.lua index ea6381f..7d401c8 100644 --- a/lua/loopbiotic/keymaps.lua +++ b/lua/loopbiotic/keymaps.lua @@ -24,6 +24,8 @@ function M.setup() M.action(keys.follow, "follow") M.action(keys.why, "why") M.action(keys.fix, "fix") + M.action(keys.goal, "goal") + M.action(keys.cancel, "cancel_turn") M.action(keys.other_lead, "other_lead") M.action(keys.stop, "stop") M.call(keys.hide, "hide") diff --git a/lua/loopbiotic/session.lua b/lua/loopbiotic/session.lua index b337b2d..b5e6371 100644 --- a/lua/loopbiotic/session.lua +++ b/lua/loopbiotic/session.lua @@ -84,6 +84,9 @@ function M.apply_turn_result(result, opts) else track_backend_errors(result.card) end + if result.card and result.card.kind ~= "working" then + state.cancelled_turn_id = nil + end require("loopbiotic.card").show(result.card) end diff --git a/lua/loopbiotic/state.lua b/lua/loopbiotic/state.lua index a0b4db9..dfadf32 100644 --- a/lua/loopbiotic/state.lua +++ b/lua/loopbiotic/state.lua @@ -2,7 +2,7 @@ ---@field statement string ---@field completed_steps string[] ---@field known_observations table[] ----@field status string "active" | "needs_review" | "complete" +---@field status string "idle" | "active" | "paused" | "needs_review" | "complete" ---@field next_step? string ---@class LoopbioticTokenUsage @@ -57,6 +57,7 @@ ---@field details_card LoopbioticCard|nil ---@field details_expanded boolean ---@field navigated_card LoopbioticCard|nil +---@field cancelled_turn_id string|nil ---@field reset fun() -- Every mutable field with its initial value. Fields that start as nil list @@ -106,6 +107,7 @@ local defaults = { details_card = vim.NIL, details_expanded = false, navigated_card = vim.NIL, + cancelled_turn_id = vim.NIL, } ---@type LoopbioticState diff --git a/lua/loopbiotic/version.lua b/lua/loopbiotic/version.lua index 6929aa8..eb10f0e 100644 --- a/lua/loopbiotic/version.lua +++ b/lua/loopbiotic/version.lua @@ -1,4 +1,4 @@ return { plugin = "0.3.2", - protocol = 9, + protocol = 10, } diff --git a/rust/crates/loopbiotic_backends/src/claude_app.rs b/rust/crates/loopbiotic_backends/src/claude_app.rs index c4b567c..9b517cd 100644 --- a/rust/crates/loopbiotic_backends/src/claude_app.rs +++ b/rust/crates/loopbiotic_backends/src/claude_app.rs @@ -32,23 +32,23 @@ The discriminator field is named "op". Allowed ops, with exact shapes: - {"op":"summary","title":string,"summary":string,"changed_files":[string]} - {"op":"error","title":string,"message":string} LOC is an object {"file":string,"line":int,"column":int,"annotation":string|null} with 1-based line and column; never a plain string. -choice option action is one of follow|why|fix|other_lead|retry|edit_prompt|open|run_check|next|stop. +choice option action is one of follow|why|fix|goal|other_lead|retry|edit_prompt|open|run_check|stop. Use deny when you cannot or should not proceed (ambiguous prompt, missing information, out-of-scope request); reason is shown to the user. error is only for technical failures. If you can only proceed from a different file or location — for example the change belongs in another file than the supplied buffer — return open_location IMMEDIATELY with that exact place instead of attempting a patch. The editor asks the user for permission, opens the file, and the next message continues this same turn with a.kind "location_granted" and fresh ctx for that buffer; then produce the real op. Never draft a patch against a file that is not the supplied buffer. Use deny only for refusals that navigation cannot solve. limits.expected, when set, names the op you must return (deny is always allowed instead; a clarifying choice is also accepted for hypothesis and finding). When limits.expected is null, choose whichever op fits best and ask via choice when the request is ambiguous. -Patch only for fix actions or when limits.goal_completion is true. patch.diff must be unified diff hunks starting with @@ against the corresponding project source. -When limits.goal_completion is true, drive the original goal one file slice per work turn: return exactly ONE file's complete patch — the most foundational unresolved file first — plus plan listing the remaining files (file + one-line summary each) and complete. Set plan.complete=true only on the final slice; remaining is then empty. Inspect the file with read-only tools; tool reads are valid patch source because Loopbiotic verifies every hunk against the live editor buffer before review. Loopbiotic reviews the slice locally, then asks for the next planned slice. Stay within limits.hunks_per_patch and limits.changed_lines for that one file. Create a missing file as its own slice before slices that reference it. Set goal_complete=true only together with plan.complete=true. Use open_location only when a required source cannot be inspected. Return summary only when every stated requirement was already satisfied. Continue automatically from completed_steps and never repeat accepted work. +Patch only for fix actions or when limits.goal_completion is true. When limits.conversation_only is true, never return patch or summary. patch.diff must be unified diff hunks starting with @@ against the corresponding project source. +When limits.goal_completion is true, advance the explicitly authorized goal one small, compilable hunk per work turn. Return at most one file and exactly one hunk within limits.changed_lines plus plan listing remaining coherent steps; a file may repeat. Set plan.complete=true only on the final step. A concise finding or choice is allowed when programmer attention is needed. Inspect only enough source for the next step, preserve completed_steps, and never repeat accepted work. When limits.goal_completion is true and limits.expected is finding because the user asked why, explain the currently pending hunk without replacing it or advancing the goal. The same draft remains pending after the answer. -A non-goal patch is one small local pair-programming step: one file, one hunk, no more changed lines than the supplied limit; its plan is null. A goal patch contains the complete change for one file and may contain multiple hunks within the supplied limits. +A patch is one small local pair-programming step: one file, one hunk, no more changed lines than the supplied limit. Non-goal patches have a null plan. Prefer the supplied context; you may use at most two targeted read-only searches when it is insufficient. Never edit files or run commands."#; /// Keeps `claude` CLI processes alive across turns using its stream-json /// stdin/stdout mode. Each Loopbiotic session gets up to two processes: a discovery /// process (hypothesis/finding/choice turns, optionally on a faster model /// with a capped thinking budget) and a patch process (full model). Separate -/// processes let a speculative patch prefetch run while the user keeps -/// navigating discovery cards, and are required anyway because the CLI cannot -/// switch models within one process. +/// processes let explicit-goal patch continuation run independently from +/// read-only conversation, and are required because the CLI cannot switch +/// models within one process. pub struct ClaudeAppBackend { command: String, args: Vec, @@ -706,6 +706,22 @@ impl BackendAdapter for ClaudeAppBackend { self.warm_up().await } + async fn cancel_turn(&self, session_id: &str) -> Result<()> { + let slots = { + let state = self.state.lock().await; + if state.session_key.as_deref() != Some(session_id) { + return Ok(()); + } + state.slots.values().cloned().collect::>() + }; + + for slot in slots { + slot.lock().await.kill_process(); + } + + Ok(()) + } + async fn identity(&self) -> BackendIdentity { // `model` names the patch-phase model — the one that writes code. // A pinned discovery model is reported separately via `phases` so a @@ -786,6 +802,7 @@ fn turn_prompt(req: &BackendRequest, include_context: bool) -> String { "hunks_per_patch": req.card_contract.max_hunks_per_patch, "changed_lines": req.card_contract.max_changed_lines, "goal_completion": req.card_contract.allow_goal_completion, + "conversation_only": req.card_contract.conversation_only, "expected": req.card_contract.expected_kind, }), ), @@ -797,11 +814,11 @@ fn turn_prompt(req: &BackendRequest, include_context: bool) -> String { json!( if matches!( req.action, - crate::BackendAction::User(loopbiotic_protocol::Action::Next) + crate::BackendAction::User(loopbiotic_protocol::Action::Goal) ) { - "Continue with the next planned file slice: one file's complete patch plus the refreshed plan." + "Continue with the next planned coherent step: one small hunk plus the refreshed plan." } else { - "Return exactly one file's complete patch (the most foundational unresolved file first) plus plan {remaining:[{file,summary}],complete}." + "Return exactly one small, compilable hunk plus plan {remaining:[{file,summary}],complete}." } ), )); @@ -813,6 +830,10 @@ fn turn_prompt(req: &BackendRequest, include_context: bool) -> String { fields.push(("known_observations", json!(req.session.known_observations))); // Volatile: changes every turn, so it must trail everything cacheable. + fields.push(( + "interaction_feedback", + json!(req.session.interaction_feedback), + )); fields.push(("a", action_value(&req.action))); fields.push(("last", json!(req.session.last_summary))); fields.push(("n", json!(req.session.card_count))); @@ -1226,12 +1247,12 @@ mod tests { req.card_contract.allow_goal_completion = true; req.card_contract.expected_kind = None; let goal = turn_prompt(&req, true); - assert!(goal.contains("exactly one file's complete patch")); - assert!(!goal.contains("next planned file slice")); + assert!(goal.contains("exactly one small, compilable hunk")); + assert!(!goal.contains("next planned coherent step")); - req.action = crate::BackendAction::User(loopbiotic_protocol::Action::Next); + req.action = crate::BackendAction::User(loopbiotic_protocol::Action::Goal); let continuation = turn_prompt(&req, true); - assert!(continuation.contains("Continue with the next planned file slice")); + assert!(continuation.contains("Continue with the next planned coherent step")); // The goal "why" turn explains the pending hunk; it must not be asked // to produce another slice. diff --git a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs index 112185b..b7a8700 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs @@ -19,20 +19,22 @@ use crate::support::{ optional_env, report_progress, turn_phase, turn_timeout_from_env, }; use crate::{ - BackendAdapter, BackendIdentity, BackendMetadata, BackendRequest, BackendResponse, - ProgressReporter, enforce_card_contract, estimate_tokens, + BackendAdapter, BackendIdentity, BackendMetadata, BackendPhaseModels, BackendRequest, + BackendResponse, ProgressReporter, enforce_card_contract, estimate_tokens, }; -use transport::{CodexAppProcess, CodexAppState, TurnOutput}; +use transport::{ActiveTurn, CodexAppProcess, CodexAppState, TurnOutput}; /// Keeps discovery and patch work on independent app-server processes. The -/// split lets a speculative patch run while the user continues discovery, -/// matching the phase-isolated process model used by the Claude adapter. +/// split lets explicit-goal continuation run independently from read-only +/// conversation, matching the phase-isolated Claude adapter. pub struct CodexAppBackend { command: String, args: Vec, model: Option, effort: Option, + discovery_model: Option, + discovery_effort: Option, turn_timeout: Option, discovery: Arc>, patch: Arc>, @@ -48,8 +50,19 @@ impl CodexAppBackend { )?; let model = optional_env("LOOPBIOTIC_CODEX_MODEL"); let effort = optional_env("LOOPBIOTIC_CODEX_EFFORT").or_else(|| Some("low".into())); + let discovery_model = optional_env("LOOPBIOTIC_CODEX_DISCOVERY_MODEL") + .or_else(|| Some("gpt-5.4-mini".into())); + let discovery_effort = + optional_env("LOOPBIOTIC_CODEX_DISCOVERY_EFFORT").or_else(|| Some("low".into())); - Ok(Self::new(command, args, model, effort)) + Ok(Self::with_phase_models( + command, + args, + model, + effort, + discovery_model, + discovery_effort, + )) } pub fn new( @@ -61,6 +74,25 @@ impl CodexAppBackend { Self::with_turn_timeout(command, args, model, effort, turn_timeout_from_env()) } + pub fn with_phase_models( + command: impl Into, + args: Vec, + model: Option, + effort: Option, + discovery_model: Option, + discovery_effort: Option, + ) -> Self { + Self::with_phase_turn_timeout( + command, + args, + model, + effort, + discovery_model, + discovery_effort, + turn_timeout_from_env(), + ) + } + /// Internal constructor that fixes the per-turn deadline instead of /// reading it from the environment; tests use it to avoid env races. pub(crate) fn with_turn_timeout( @@ -69,18 +101,54 @@ impl CodexAppBackend { model: Option, effort: Option, turn_timeout: Option, + ) -> Self { + Self::with_phase_turn_timeout( + command, + args, + model.clone(), + effort.clone(), + model, + effort, + turn_timeout, + ) + } + + pub(crate) fn with_phase_turn_timeout( + command: impl Into, + args: Vec, + model: Option, + effort: Option, + discovery_model: Option, + discovery_effort: Option, + turn_timeout: Option, ) -> Self { Self { command: command.into(), args, model, effort, + discovery_model, + discovery_effort, turn_timeout, discovery: Arc::new(Mutex::new(CodexAppState::default())), patch: Arc::new(Mutex::new(CodexAppState::default())), } } + fn phase_model(&self, phase: Phase) -> Option { + match phase { + Phase::Discovery => self.discovery_model.clone(), + Phase::Patch => self.model.clone(), + } + } + + fn phase_effort(&self, phase: Phase) -> Option { + match phase { + Phase::Discovery => self.discovery_effort.clone(), + Phase::Patch => self.effort.clone(), + } + } + fn lane(&self, phase: Phase) -> Arc> { match phase { Phase::Discovery => self.discovery.clone(), @@ -165,7 +233,7 @@ impl CodexAppBackend { "You are a local Loopbiotic pair-programming partner. You may use at most two targeted read-only project tool calls to find the next relevant code block. Stop searching once the supplied context supports an exact location. Never edit files. Return exactly one final JSON object matching the supplied output schema and no prose." }; let developer_instructions = if goal_loop { - "Drive the original goal from start to finish, one file slice per work turn: return exactly one file's complete patch plus a plan of the remaining files, and continue with the next planned slice when asked; Loopbiotic reviews each slice's hunks locally. When the user asks why, explain the pending hunk without advancing or replacing it. Preserve progress across turns and do not repeat accepted work." + "Advance an explicitly authorized goal one small, compilable hunk at a time. Return one patch hunk plus a plan of remaining coherent steps, or a concise finding/choice when user attention is needed. Loopbiotic reviews every hunk locally. Preserve accepted progress and never repeat completed work." } else if patch_turn { "Work as an equal pair-programming partner. Propose one coherent local block at the supplied location and explain why this is the useful next move. Do not take over the whole task. Return one structured patch hunk as an editable draft, not a finished agenda." } else { @@ -260,7 +328,10 @@ impl CodexAppBackend { ) -> Result { Self::ensure(state, &self.command, &self.args).await?; - let thread_id = Self::thread_id(state, req, &self.model).await?; + let phase = turn_phase(req); + let model = self.phase_model(phase); + let effort = self.phase_effort(phase); + let thread_id = Self::thread_id(state, req, &model).await?; let fingerprint = context_fingerprint(req); let include_context = state.context_fingerprints.get(&thread_id) != Some(&fingerprint); state @@ -290,8 +361,8 @@ impl CodexAppBackend { "text": input, "text_elements": [] }], - "model": self.model, - "effort": self.effort, + "model": model, + "effort": effort, "outputSchema": schema::output_schema(req) } })) @@ -303,6 +374,11 @@ impl CodexAppBackend { .and_then(Value::as_str) .ok_or_else(|| anyhow!("codex app-server turn/start returned no turn id"))?; let turn_id = turn_id.to_string(); + state.active_turn = Some(ActiveTurn { + session_id: req.session.id.clone(), + thread_id, + turn_id: turn_id.clone(), + }); debug("codex turn started"); report_progress( @@ -311,7 +387,15 @@ impl CodexAppBackend { "working", "Codex is processing the request", ); - state.read_turn(&turn_id, &req.session.id, progress).await + let output = state.read_turn(&turn_id, &req.session.id, progress).await; + if state + .active_turn + .as_ref() + .is_some_and(|active| active.turn_id == turn_id) + { + state.active_turn = None; + } + output } async fn warm_up(&self) -> Result<()> { @@ -364,7 +448,7 @@ impl BackendAdapter for CodexAppBackend { raw_output: Some(output.text.clone()), metadata: BackendMetadata { backend: "codex_app".into(), - model: self.model.clone(), + model: self.phase_model(turn_phase(&req)), token_usage: output.token_usage.or_else(|| { Some(TokenUsage::estimated( estimate_tokens(&prompt(&req, true)), @@ -381,15 +465,52 @@ impl BackendAdapter for CodexAppBackend { self.warm_up().await } + async fn cancel_turn(&self, session_id: &str) -> Result<()> { + for lane in [self.discovery.clone(), self.patch.clone()] { + let mut state = lane.lock().await; + match state.interrupt_turn(session_id).await { + Ok(true) => {} + Ok(false) => { + // The daemon may abort while initialize, thread/start, or + // turn/start is still awaiting its response, before an + // active turn id can be recorded. A fresh app-server is + // the only way to guarantee that work also stops. + if state.process.is_some() { + state.kill_process(); + } + } + Err(error) => { + state.kill_process(); + return Err(anyhow!("failed to interrupt Codex turn: {error}")); + } + } + } + + Ok(()) + } + async fn identity(&self) -> BackendIdentity { + let patch = self.model.clone(); + let discovery = self.discovery_model.clone().or_else(|| patch.clone()); + let phases = (discovery != patch).then(|| BackendPhaseModels { + discovery: discovery.clone(), + patch: patch.clone(), + }); + let mut models = vec![]; + for candidate in [&patch, &discovery].into_iter().flatten() { + if !models.contains(candidate) { + models.push(candidate.clone()); + } + } + BackendIdentity { backend: "codex_app".into(), // The app-server initialize handshake reports no default model or // model list, so only the configured model can be named; turns // with model: null use the server's own default. - model: self.model.clone(), - models: vec![], - phases: None, + model: patch, + models, + phases, } } @@ -416,6 +537,7 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { let goal_loop = req.card_contract.allow_goal_completion; let goal_question = goal_loop && req.card_contract.expected_kind == Some(loopbiotic_protocol::CardKind::Finding); + let post_accept = matches!(req.action, crate::BackendAction::PostAccept); let turn_rules = if goal_question { "- Explain why the currently pending patch is the right next step for the original goal.\n\ - Address its behavior, tradeoffs, and relevant evidence from the code.\n\ @@ -425,26 +547,25 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { } else if goal_loop { let lead = if matches!( req.action, - crate::BackendAction::User(loopbiotic_protocol::Action::Next) + crate::BackendAction::User(loopbiotic_protocol::Action::Goal) ) { - "- Continue with the next planned file slice; the previous slice was accepted.\n" + "- Continue with the next planned coherent step; the previous hunk was accepted.\n" } else { "" }; format!( "{lead}\ - Continue executing the original session goal from the accepted progress; never restart or repeat a completed step.\n\ - - Return exactly one file's complete patch in this response: the most foundational unresolved file first. Never batch a second file into the same response.\n\ - - Inspect that file with targeted read-only tools. Tool reads are valid patch source: Loopbiotic verifies every returned hunk against the corresponding live editor buffer before review.\n\ - - The file's patch may use up to {} hunks and at most {} added/removed lines per hunk.\n\ - - With the patch return plan: the remaining files that still need their own slice, each with a one-line summary, plus complete. Set complete=true only when this patch is the final slice; remaining is then empty.\n\ - - Create a missing file as its own slice before the slices that reference it.\n\ + - Return at most one file and exactly one coherent, compilable hunk changing at most {} added/removed lines.\n\ + - Inspect only enough project context to produce that next step. Tool reads are valid patch source because Loopbiotic verifies the hunk before review.\n\ + - With the patch return plan: list the remaining coherent steps, each with its target file and one-line summary. A file may appear more than once. Set complete=true only when this hunk is the final step.\n\ + - Create a missing file incrementally before steps that reference it.\n\ - Use open_location only when a required source cannot be inspected with read-only project tools.\n\ - Set goal_complete=true only together with plan.complete=true.\n\ - Return summary only when every requirement in the original goal is satisfied; cite the completed result.\n\ - Return choice only when a genuine user decision blocks all safe progress.\n\ - - Do not return a finding, an assessment, or instructions for the user to request another draft.", - req.card_contract.max_hunks_per_patch, req.card_contract.max_changed_lines, + - A concise finding is allowed when the programmer should see evidence before another draft.", + req.card_contract.max_changed_lines, ) } else if patch_turn { format!( @@ -458,6 +579,12 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { - Use only the supplied buffer excerpt. Do not inspect the project or use tools.", req.card_contract.max_changed_lines ) + } else if post_accept { + "- The programmer accepted the previous local draft.\n\ + - Respond with the most useful immediate observation, verification target, or concise question.\n\ + - This is read-only conversational follow-up: never return another patch or a completion summary.\n\ + - Keep the response compact and hand control back immediately." + .into() } else { "- Find only one useful next move, not a plan for the whole solution.\n\ - Inspect the supplied ranked project context first. Use targeted project search only when those fragments are insufficient.\n\ @@ -465,14 +592,15 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { - When the user names a destination or consumer such as a template, API, caller, or renderer, prefer that consumer block as the next location before changing its producer.\n\ - Explain what you noticed, why it matters now, and how the code led you there. Do not dictate keystrokes or a line-by-line walkthrough.\n\ - Return a concrete evidence/next/location pointing to that block so the editor can move there before Fix.\n\ - - Do not propose code changes yet; hand the keyboard back after identifying the next move." + - Never return a patch or completion summary on a conversational turn; hand the keyboard back after the answer." .into() }; let output_contract = if goal_question { "- finding: concise explanation of the pending hunk" } else if goal_loop { - "- patch: one file's complete structured patch for local hunk-by-hunk review; include goal_complete and plan {remaining: [{file, summary}], complete}\n\ + "- patch: one small structured hunk for local review; include goal_complete and plan {remaining: [{file, summary}], complete}\n\ +- finding or hypothesis: concise evidence that needs programmer attention before another hunk\n\ - open_location: when the next hunk belongs in another buffer; put the target in location (not next) and the explanation in reason (not message)\n\ - choice: only for a blocking user decision\n\ - summary: only when the complete original goal is satisfied" @@ -536,6 +664,7 @@ Mode: {mode} Required card kind: {expected_kind}. Return that exact kind. Completed local steps: {completed_steps} Known findings and signals (do not repeat): {known_observations} +Interaction feedback: {interaction_feedback} Action: {action} Last card: {last} {source_context}"#, @@ -544,6 +673,8 @@ Last card: {last} serde_json::to_string(&req.session.completed_steps).unwrap_or_else(|_| "[]".into()), known_observations = serde_json::to_string(&req.session.known_observations).unwrap_or_else(|_| "[]".into()), + interaction_feedback = serde_json::to_string(&req.session.interaction_feedback) + .unwrap_or_else(|_| "[]".into()), mode = serde_json::to_string(&req.session.mode).unwrap_or_else(|_| "\"auto\"".into()), action = action_value(&req.action), expected_kind = req @@ -577,6 +708,7 @@ mod tests { session: crate::SessionSnapshot { id: "s_1".into(), prompt: "inspect target".into(), + interaction_feedback: vec![], completed_steps: vec![], known_observations: vec![], mode: loopbiotic_protocol::Mode::Auto, @@ -663,7 +795,28 @@ mod tests { assert_eq!(identity.backend, "codex_app"); assert_eq!(identity.model.as_deref(), Some("gpt-5.3-codex")); - assert!(identity.models.is_empty()); + assert_eq!(identity.models, vec!["gpt-5.3-codex"]); + assert!(identity.phases.is_none()); + } + + #[tokio::test] + async fn identity_keeps_the_patch_model_primary_when_discovery_differs() { + let backend = CodexAppBackend::with_phase_models( + "codex-unused", + vec![], + Some("gpt-patch".into()), + Some("medium".into()), + Some("gpt-fast".into()), + Some("low".into()), + ); + + let identity = backend.identity().await; + + assert_eq!(identity.model.as_deref(), Some("gpt-patch")); + let phases = identity.phases.expect("phase identity"); + assert_eq!(phases.patch.as_deref(), Some("gpt-patch")); + assert_eq!(phases.discovery.as_deref(), Some("gpt-fast")); + assert_eq!(identity.models, vec!["gpt-patch", "gpt-fast"]); } #[test] @@ -776,10 +929,10 @@ mod tests { let prompt = prompt(&request, true); assert!(prompt.contains("Tool reads are valid patch source")); - assert!(prompt.contains("exactly one file's complete patch")); + assert!(prompt.contains("exactly one coherent, compilable hunk")); assert!(prompt.contains("With the patch return plan")); - assert!(prompt.contains("complete=true only when this patch is the final slice")); - assert!(!prompt.contains("Continue with the next planned file slice")); + assert!(prompt.contains("complete=true only when this hunk is the final step")); + assert!(!prompt.contains("Continue with the next planned coherent step")); } #[test] @@ -787,11 +940,11 @@ mod tests { let mut request = request(); request.card_contract.allow_goal_completion = true; request.card_contract.expected_kind = None; - request.action = BackendAction::User(Action::Next); + request.action = BackendAction::User(Action::Goal); let prompt = prompt(&request, true); - assert!(prompt.contains("Continue with the next planned file slice")); - assert!(prompt.contains("exactly one file's complete patch")); + assert!(prompt.contains("Continue with the next planned coherent step")); + assert!(prompt.contains("exactly one coherent, compilable hunk")); } #[test] @@ -804,6 +957,6 @@ mod tests { request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Hypothesis); let discovery_prompt = prompt(&request, true); - assert!(!discovery_prompt.contains("exactly one file's complete patch")); + assert!(!discovery_prompt.contains("With the patch return plan")); } } diff --git a/rust/crates/loopbiotic_backends/src/codex_app/schema.rs b/rust/crates/loopbiotic_backends/src/codex_app/schema.rs index 213c0e6..a0eea85 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/schema.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/schema.rs @@ -14,6 +14,9 @@ pub(super) fn output_schema(req: &BackendRequest) -> Value { if req.card_contract.allow_goal_completion { return goal_loop_schema(&req.card_contract); } + if req.card_contract.conversation_only { + return conversation_schema(); + } match req.card_contract.expected_kind { Some(loopbiotic_protocol::CardKind::Patch) => patch_schema(&req.card_contract), @@ -23,10 +26,24 @@ pub(super) fn output_schema(req: &BackendRequest) -> Value { Some(loopbiotic_protocol::CardKind::Deny) => deny_schema(), Some(loopbiotic_protocol::CardKind::Summary) => summary_schema(), Some(loopbiotic_protocol::CardKind::Error) => error_schema(), + Some(loopbiotic_protocol::CardKind::Working) => error_schema(), Some(loopbiotic_protocol::CardKind::OpenLocation) | None => any_op_schema(), } } +fn conversation_schema() -> Value { + let mut schema = any_op_schema(); + schema["properties"]["op"]["enum"] = json!([ + "hypothesis", + "finding", + "choice", + "deny", + "open_location", + "error" + ]); + schema +} + /// Schema for turns without a demanded kind: the agent picks whichever op /// fits, including a clarifying choice or a deny. Mirrors /// schemas/loopbiotic-agent-op.schema.json (every field present, unused ones null). @@ -84,7 +101,7 @@ fn any_op_schema() -> Value { "label": {"type": "string"}, "action": { "type": "string", - "enum": ["follow", "why", "fix", "other_lead", "retry", "edit_prompt", "open", "run_check", "next", "stop"] + "enum": ["follow", "why", "fix", "goal", "other_lead", "retry", "edit_prompt", "open", "run_check", "stop"] } }) ) @@ -101,6 +118,8 @@ fn goal_loop_schema(contract: &crate::CardContract) -> Value { let mut schema = any_op_schema(); schema["properties"]["op"]["enum"] = json!([ "patch", + "hypothesis", + "finding", "choice", "deny", "open_location", @@ -110,8 +129,8 @@ fn goal_loop_schema(contract: &crate::CardContract) -> Value { let mut patches = patch_schema(contract)["properties"]["patches"].clone(); patches["type"] = json!(["array", "null"]); schema["properties"]["patches"] = patches; - // Sliced goal turns return one file's patch plus the plan of remaining - // slices; null keeps legacy full-batch responses and non-patch ops legal. + // Goal patch turns return one hunk plus the remaining coherent steps. + // Null stays legal for attention cards and a planless final hunk. schema["properties"]["plan"] = json!({ "anyOf": [plan_schema(), {"type": "null"}] }); @@ -269,7 +288,7 @@ fn choice_schema() -> Value { "label": {"type": "string"}, "action": { "type": "string", - "enum": ["follow", "why", "fix", "other_lead", "retry", "edit_prompt", "open", "run_check", "next", "stop"] + "enum": ["follow", "why", "fix", "goal", "other_lead", "retry", "edit_prompt", "open", "run_check", "stop"] } }) ) @@ -328,12 +347,25 @@ mod tests { assert_eq!(patch["properties"]["hunks"]["maxItems"], 1); } + #[test] + fn conversation_schema_cannot_return_patch_or_summary() { + let mut req = crate::test_request(); + req.card_contract.conversation_only = true; + let schema = output_schema(&req); + let ops = schema["properties"]["op"]["enum"].as_array().unwrap(); + + assert!(ops.contains(&json!("finding"))); + assert!(ops.contains(&json!("choice"))); + assert!(!ops.contains(&json!("patch"))); + assert!(!ops.contains(&json!("summary"))); + } + #[test] fn goal_loop_schema_allows_structured_patch_or_summary() { let contract = crate::CardContract { - max_patch_files: loopbiotic_protocol::MAX_GOAL_PATCH_FILES, - max_hunks_per_patch: loopbiotic_protocol::MAX_GOAL_HUNKS_PER_PATCH, - max_changed_lines: loopbiotic_protocol::MAX_GOAL_CHANGED_LINES, + max_patch_files: loopbiotic_protocol::MAX_PATCH_FILES, + max_hunks_per_patch: loopbiotic_protocol::MAX_HUNKS_PER_PATCH, + max_changed_lines: loopbiotic_protocol::MAX_CHANGED_LINES, ..Default::default() }; let schema = goal_loop_schema(&contract); @@ -341,15 +373,15 @@ mod tests { assert!(ops.contains(&json!("patch"))); assert!(ops.contains(&json!("summary"))); - assert!(!ops.contains(&json!("finding"))); + assert!(ops.contains(&json!("finding"))); assert_eq!( schema["properties"]["patches"]["maxItems"], - loopbiotic_protocol::MAX_GOAL_PATCH_FILES + loopbiotic_protocol::MAX_PATCH_FILES ); assert!(schema["properties"]["patches"]["items"]["properties"]["hunks"].is_object()); assert_eq!( schema["properties"]["patches"]["items"]["properties"]["hunks"]["maxItems"], - loopbiotic_protocol::MAX_GOAL_HUNKS_PER_PATCH + loopbiotic_protocol::MAX_HUNKS_PER_PATCH ); assert!(schema["properties"]["goal_complete"].is_object()); } diff --git a/rust/crates/loopbiotic_backends/src/codex_app/transport.rs b/rust/crates/loopbiotic_backends/src/codex_app/transport.rs index c59905b..320efac 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/transport.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/transport.rs @@ -20,6 +20,14 @@ pub(super) struct CodexAppState { next_id: u64, pub(super) threads: HashMap, pub(super) context_fingerprints: HashMap, + pub(super) active_turn: Option, +} + +#[derive(Clone, Debug)] +pub(super) struct ActiveTurn { + pub(super) session_id: String, + pub(super) thread_id: String, + pub(super) turn_id: String, } pub(super) struct CodexAppProcess { @@ -48,6 +56,7 @@ impl Default for CodexAppState { next_id: 1, threads: HashMap::new(), context_fingerprints: HashMap::new(), + active_turn: None, } } } @@ -56,6 +65,7 @@ impl CodexAppState { pub(super) fn clear_conversation(&mut self) { self.threads.clear(); self.context_fingerprints.clear(); + self.active_turn = None; } pub(super) fn invalidate_process(&mut self) { @@ -72,6 +82,28 @@ impl CodexAppState { self.invalidate_process(); } + pub(super) async fn interrupt_turn(&mut self, session_id: &str) -> Result { + let Some(active) = self + .active_turn + .clone() + .filter(|active| active.session_id == session_id) + else { + return Ok(false); + }; + + let result = self + .request(json!({ + "method": "turn/interrupt", + "params": { + "threadId": active.thread_id, + "turnId": active.turn_id, + } + })) + .await; + self.active_turn = None; + result.map(|_| true) + } + fn next_request_id(&mut self) -> u64 { let id = self.next_id; self.next_id += 1; diff --git a/rust/crates/loopbiotic_backends/src/generic.rs b/rust/crates/loopbiotic_backends/src/generic.rs index cd9c3b7..62cb03d 100644 --- a/rust/crates/loopbiotic_backends/src/generic.rs +++ b/rust/crates/loopbiotic_backends/src/generic.rs @@ -65,7 +65,7 @@ impl GenericCliBackend { /// The op contract sent on every turn. A `const` so it can never interpolate /// volatile data: it opens the prompt and anchors the provider prompt cache. -const GENERIC_API_CONTRACT: &str = "Return one JSON Loopbiotic op only. No prose. Ops: hypothesis(title,claim,evidence,next), finding(title,finding,location,annotation), patch(title,explanation,goal_complete,plan,patches), choice(title,question,options), deny(title,reason,location), open_location(reason,location), summary(title,summary,changed_files), error(title,message). choice.options items are {id,label,action} objects; action is one of follow|why|fix|other_lead|retry|edit_prompt|open|run_check|next|stop. Use deny when you cannot or should not proceed (ambiguous prompt, missing information, out-of-scope request); reason is shown to the user. Outside goal completion, return open_location when the change belongs in a different file than the supplied buffer. Goal slices may patch any file inspected with read-only tools. error is only for technical failures. limits.expected, when set, is the required op (deny always allowed; choice also allowed for hypothesis/finding); when null, choose the best fitting op and ask via choice when ambiguous. Patch only for fix. patch.diff must be unified diff hunks starting with @@. Unused schema fields null."; +const GENERIC_API_CONTRACT: &str = "Return one JSON Loopbiotic op only. No prose. Ops: hypothesis(title,claim,evidence,next), finding(title,finding,location,annotation), patch(title,explanation,goal_complete,plan,patches), choice(title,question,options), deny(title,reason,location), open_location(reason,location), summary(title,summary,changed_files), error(title,message). choice.options items are {id,label,action} objects; action is one of follow|why|fix|goal|other_lead|retry|edit_prompt|open|run_check|stop. Use deny when you cannot or should not proceed. When limits.conversation_only is true, never return patch or summary. Goal turns are explicitly user-authorized. error is only for technical failures. limits.expected, when set, is the required op. patch.diff must be unified diff hunks starting with @@. Unused schema fields null."; pub(crate) fn generic_prompt(req: &BackendRequest) -> String { let mut rules = vec![ @@ -89,10 +89,10 @@ pub(crate) fn generic_prompt(req: &BackendRequest) -> String { // the rules array survives across goal and non-goal turns. if req.card_contract.allow_goal_completion { rules.push(json!( - "Goal turn: return exactly one file's complete patch per response — the most foundational unresolved file first — plus plan {remaining:[{file,summary}],complete}. Set plan.complete true only on the final slice; remaining is then empty. Use up to limits.hunks_per_patch hunks and limits.changed_lines per hunk for that one file; Loopbiotic verifies and reviews the slice locally, then asks for the next planned slice." + "Goal turn: return one small, compilable hunk within limits.changed_lines plus plan {remaining:[{file,summary}],complete}. Remaining entries are coherent steps and may repeat a file. A finding or choice is allowed when programmer attention is needed." )); rules.push(json!( - "On a goal continuation (a.action is next), continue with the next planned file slice." + "On a goal continuation (a.action is goal), continue with the next planned coherent step." )); rules.push(json!( "If limits.goal_completion is true and limits.expected is finding, explain why the pending hunk is the right next step without replacing it or advancing the goal." @@ -140,6 +140,7 @@ pub(crate) fn generic_prompt(req: &BackendRequest) -> String { "hunks_per_patch": req.card_contract.max_hunks_per_patch, "changed_lines": req.card_contract.max_changed_lines, "goal_completion": req.card_contract.allow_goal_completion, + "conversation_only": req.card_contract.conversation_only, "expected": req.card_contract.expected_kind, }), ), @@ -147,6 +148,10 @@ pub(crate) fn generic_prompt(req: &BackendRequest) -> String { ("completed_steps", json!(req.session.completed_steps)), ("known_observations", json!(req.session.known_observations)), // Volatile: changes every turn. + ( + "interaction_feedback", + json!(req.session.interaction_feedback), + ), ("a", action_value(&req.action)), ("last", json!(req.session.last_summary)), ("n", json!(req.session.card_count)), @@ -394,13 +399,13 @@ mod tests { #[test] fn generic_prompt_adds_the_slice_rule_only_on_goal_turns() { let mut req = crate::test_request(); - assert!(!generic_prompt(&req).contains("exactly one file's complete patch")); + assert!(!generic_prompt(&req).contains("one small, compilable hunk")); req.card_contract.allow_goal_completion = true; let goal = generic_prompt(&req); - assert!(goal.contains("exactly one file's complete patch")); + assert!(goal.contains("one small, compilable hunk")); assert!(goal.contains("plan {remaining:[{file,summary}],complete}")); - assert!(goal.contains("next planned file slice")); + assert!(goal.contains("next planned coherent step")); } #[test] diff --git a/rust/crates/loopbiotic_backends/src/lib.rs b/rust/crates/loopbiotic_backends/src/lib.rs index a44929e..8179eaf 100644 --- a/rust/crates/loopbiotic_backends/src/lib.rs +++ b/rust/crates/loopbiotic_backends/src/lib.rs @@ -44,6 +44,13 @@ pub trait BackendAdapter: Send + Sync { Ok(()) } + /// Interrupts in-flight work for one Loopbiotic session. Persistent + /// backends override this so cancelling a Working card stops the actual + /// agent, not only the daemon future waiting for it. + async fn cancel_turn(&self, _session_id: &str) -> Result<()> { + Ok(()) + } + /// Reports who will answer the next turn: the adapter name, the concrete /// model it resolves to (configured, else discovered, else unknown), and /// any models the backend can enumerate for switching. @@ -126,6 +133,9 @@ pub enum BackendAction { Start, User(Action), Reply(String), + /// Continue after an accepted non-goal patch with a read-only, + /// conversational response. + PostAccept, ContractRetry(String), // The editor granted an open_location request mid-turn; the request's // context carries the freshly opened buffer. @@ -138,6 +148,9 @@ pub struct CardContract { #[serde(skip_serializing_if = "Option::is_none")] pub expected_kind: Option, pub allow_goal_completion: bool, + /// Conversational turns may answer, point to evidence, or ask a question, + /// but must never return a patch or completion summary. + pub conversation_only: bool, pub max_body_chars: usize, pub max_patch_files: usize, pub max_hunks_per_patch: usize, @@ -150,6 +163,7 @@ impl Default for CardContract { one_card_only: true, expected_kind: None, allow_goal_completion: false, + conversation_only: false, max_body_chars: 1_200, max_patch_files: MAX_PATCH_FILES, max_hunks_per_patch: MAX_HUNKS_PER_PATCH, @@ -165,6 +179,19 @@ pub fn enforce_card_contract( raw_output: &str, ) -> Card { let Some(expected_kind) = contract.expected_kind else { + if contract.conversation_only + && matches!(card, Card::Patch(_) | Card::Summary(_) | Card::Working(_)) + { + return Card::Error(ErrorCard { + id: "c_backend_contract_error".into(), + title: "Backend returned work during conversation".into(), + message: format!( + "{backend} returned a {:?} card, but this turn requires a conversational response.", + card.kind() + ), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }); + } return card; }; @@ -238,6 +265,8 @@ pub fn estimate_tokens(text: &str) -> usize { pub struct SessionSnapshot { pub id: String, pub prompt: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub interaction_feedback: Vec, pub completed_steps: Vec, pub known_observations: Vec, pub mode: Mode, @@ -262,6 +291,7 @@ pub(crate) fn test_request() -> BackendRequest { session: SessionSnapshot { id: "s_1".into(), prompt: "payload is empty".into(), + interaction_feedback: vec![], completed_steps: vec![], known_observations: vec![], mode: Mode::Auto, diff --git a/rust/crates/loopbiotic_backends/src/mock.rs b/rust/crates/loopbiotic_backends/src/mock.rs index 774ef3d..9366aa5 100644 --- a/rust/crates/loopbiotic_backends/src/mock.rs +++ b/rust/crates/loopbiotic_backends/src/mock.rs @@ -36,6 +36,7 @@ impl BackendAdapter for MockBackend { BackendAction::User(Action::Stop) => stop_card(), BackendAction::User(action) => unsupported_card(action), BackendAction::Reply(text) => reply_card(text), + BackendAction::PostAccept => post_accept_card(), BackendAction::ContractRetry(_) => finding_card(), BackendAction::LocationGranted => patch_card(&req), } @@ -73,6 +74,17 @@ impl BackendAdapter for MockBackend { } } +fn post_accept_card() -> Card { + Card::Finding(FindingCard { + id: "c_post_accept".into(), + title: "Local step accepted".into(), + finding: "The accepted hunk is in place. Exercise the changed behavior before authorizing more code.".into(), + location: None, + annotation: None, + actions: vec![Action::Follow, Action::Goal, Action::RunCheck, Action::Stop], + }) +} + /// Later slices of the mock's three-slice goal. The first slice always /// targets the live buffer; these two use fixed single-line sources so tests /// (and the daemon's editor/read_file flow) can serve matching buffers. @@ -391,7 +403,7 @@ fn check_card() -> Card { finding: "Run the project check command from the editor or shell.".into(), location: None, annotation: None, - actions: vec![Action::Next, Action::Stop], + actions: vec![Action::Goal, Action::Stop], }) } diff --git a/rust/crates/loopbiotic_backends/src/stdio_agent.rs b/rust/crates/loopbiotic_backends/src/stdio_agent.rs index 1adadab..05f7eeb 100644 --- a/rust/crates/loopbiotic_backends/src/stdio_agent.rs +++ b/rust/crates/loopbiotic_backends/src/stdio_agent.rs @@ -214,6 +214,16 @@ impl BackendAdapter for StdioAgentBackend { }) } + async fn cancel_turn(&self, _session_id: &str) -> Result<()> { + let mut process = self.process.lock().await; + if let Some(active) = process.as_mut() { + let _ = active.child.start_kill(); + } + *process = None; + + Ok(()) + } + fn capabilities(&self) -> BackendInfo { BackendInfo { name: "agent_stdio".into(), @@ -239,6 +249,7 @@ fn agent_event(req: &BackendRequest) -> serde_json::Value { "s": { "id": req.session.id, "p": req.session.prompt, + "interaction_feedback": req.session.interaction_feedback, "completed_steps": req.session.completed_steps, "known_observations": req.session.known_observations, "mode": req.session.mode, @@ -253,7 +264,7 @@ fn agent_event(req: &BackendRequest) -> serde_json::Value { fn agent_api() -> serde_json::Value { json!( - "Return one JSON Loopbiotic op only. Ops: hypothesis, finding, patch, choice, deny, open_location, summary, error. Use deny(title,reason) when you cannot or should not proceed, such as an ambiguous prompt or missing information; the reason is shown to the user. error is only for technical failures. Behave as an equal pair-programming partner. Return patch for user action fix or start mode fix unless impossible. When limits.allow_goal_completion is true, inspect every required file and return the complete multi-file patch batch in one turn within the supplied file, hunk, and changed-line limits; Loopbiotic verifies and reviews its hunks locally. Set patch.goal_complete true when accepting the batch finishes the original goal. When goal completion is true and the expected card is finding because the user asked why, explain the pending hunk without replacing it or advancing the goal. A non-goal patch is one local step: exactly one file and one hunk within the supplied changed-line limit. patch.diff must be unified diff hunks starting with @@. You may first emit newline-delimited {\"t\":\"loopbiotic_progress\",\"phase\":string,\"message\":string} records with concise user-visible activity summaries. Never emit hidden reasoning or private chain-of-thought. End with either a raw Loopbiotic op or {\"t\":\"loopbiotic_result\",\"result\":}." + "Return one JSON Loopbiotic op only. Ops: hypothesis, finding, patch, choice, deny, open_location, summary, error. When limits.conversation_only is true, never return patch or summary. Return patch for user action fix or start mode fix unless impossible. Goal execution is explicit and advances one small, compilable hunk per turn with a plan of remaining coherent steps. A patch is exactly one file and one hunk within the supplied changed-line limit. You may emit loopbiotic_progress records before the result. Never emit hidden reasoning. End with either a raw Loopbiotic op or a loopbiotic_result record." ) } diff --git a/rust/crates/loopbiotic_backends/src/support.rs b/rust/crates/loopbiotic_backends/src/support.rs index 23b5347..7dcd7f7 100644 --- a/rust/crates/loopbiotic_backends/src/support.rs +++ b/rust/crates/loopbiotic_backends/src/support.rs @@ -145,6 +145,7 @@ pub(crate) fn action_value(action: &BackendAction) -> Value { json!({"kind": "user", "action": action}) } BackendAction::Reply(text) => json!({"kind": "reply", "text": text}), + BackendAction::PostAccept => json!({"kind": "post_accept"}), BackendAction::ContractRetry(reason) => { json!({"kind": "contract_retry", "reason": reason}) } diff --git a/rust/crates/loopbiotic_harness/src/engine/goal.rs b/rust/crates/loopbiotic_harness/src/engine/goal.rs index fa8707d..a8d681e 100644 --- a/rust/crates/loopbiotic_harness/src/engine/goal.rs +++ b/rust/crates/loopbiotic_harness/src/engine/goal.rs @@ -1,5 +1,5 @@ -//! Continuous-goal bookkeeping: splitting goal batches into reviewable hunk -//! cards, tracking goal progress/state, and assembling completion summaries. +//! Explicit-goal bookkeeping: tracking one reviewable hunk at a time, goal +//! progress/state, and final completion summaries. use std::collections::VecDeque; @@ -113,17 +113,15 @@ pub(super) fn queue_goal_patch_cards(session: &mut Session, card: Card) -> Resul return Ok(card); }; - // A sliced goal response: exactly one file plus a plan of what remains. - // Multi-file responses (or responses without a plan) keep the legacy - // full-batch semantics, where `goal_complete` alone decides completion. - let slice_plan = (card.patches.len() == 1) - .then(|| card.plan.clone()) - .flatten(); - let completes = slice_plan + // Validation already guarantees one file and one hunk. A plan describes + // the remaining coherent steps; a planless card may only complete via + // its explicit goal_complete flag. + let plan = card.plan.clone(); + let completes = plan .as_ref() .map(|plan| plan.complete) .unwrap_or(card.goal_complete); - session.goal_slice_continues = slice_plan.as_ref().is_some_and(|plan| !plan.complete); + session.goal_slice_continues = plan.as_ref().is_some_and(|plan| !plan.complete); let mut cards = Vec::new(); for patch in card.patches { @@ -146,7 +144,7 @@ pub(super) fn queue_goal_patch_cards(session: &mut Session, card: Card) -> Resul explanation: explanation.clone(), warnings: card.warnings.clone(), goal_complete: false, - plan: slice_plan.clone(), + plan: plan.clone(), patches: vec![loopbiotic_protocol::FilePatch { id: format!("{}_h{}", patch.id, index + 1), file: patch.file.clone(), diff --git a/rust/crates/loopbiotic_harness/src/engine/mod.rs b/rust/crates/loopbiotic_harness/src/engine/mod.rs index a618690..32c37e4 100644 --- a/rust/crates/loopbiotic_harness/src/engine/mod.rs +++ b/rust/crates/loopbiotic_harness/src/engine/mod.rs @@ -19,9 +19,9 @@ use loopbiotic_backends::{ use loopbiotic_context::ContextOptimizer; use loopbiotic_patch::PatchCoherence; use loopbiotic_protocol::{ - Action, ActionResult, Card, CardKind, ContextBundle, ContextPolicy, ErrorCard, - MAX_GOAL_CHANGED_LINES, MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, Mode, PatchApplyResult, - StartSessionParams, StartSessionResult, SummaryCard, TokenUsage, + Action, ActionResult, Card, CardKind, ContextBundle, ContextPolicy, ErrorCard, FindingCard, + Mode, PatchApplyResult, StartSessionParams, StartSessionResult, SummaryCard, TokenUsage, + WorkingCard, }; use crate::session::Session; @@ -43,12 +43,6 @@ pub struct Engine { prefetches: HashMap, /// In-flight speculative goal-continuation turns, keyed by session. continuations: HashMap, - /// Cancelled continuations still running on the backend; their usage is - /// folded into the session totals once they finish. - cancelled_continuations: Vec<( - String, - tokio::task::JoinHandle>, - )>, location_granter: Option, source_context_provider: Option, } @@ -90,7 +84,6 @@ impl Engine { prefetch_mode: PrefetchMode::Off, prefetches: HashMap::new(), continuations: HashMap::new(), - cancelled_continuations: vec![], location_granter: None, source_context_provider: None, } @@ -108,18 +101,58 @@ impl Engine { self.source_context_provider = Some(provider); } + pub fn record_interaction_feedback( + &mut self, + session_id: &str, + feedback: impl Into, + ) -> Result<()> { + let session = self + .sessions + .get_mut(session_id) + .ok_or_else(|| anyhow!("unknown session {session_id}"))?; + let feedback = feedback.into(); + if !session.interaction_feedback.contains(&feedback) { + session.interaction_feedback.push(feedback); + if session.interaction_feedback.len() > 3 { + session.interaction_feedback.remove(0); + } + } + + Ok(()) + } + pub async fn start(&mut self, params: StartSessionParams) -> Result { self.start_with_progress(params, None).await } + pub fn reserve_start(&mut self, params: StartSessionParams) -> (String, u64) { + let mut session = Session::new(params); + session.turn_generation = 1; + let session_id = session.id.clone(); + self.sessions.insert(session_id.clone(), session); + + (session_id, 1) + } + pub async fn start_with_progress( &mut self, params: StartSessionParams, progress: Option, ) -> Result { - let mut session = Session::new(params.clone()); + let (session_id, generation) = self.reserve_start(params); + self.complete_start_with_progress(&session_id, generation, progress) + .await + } + + pub async fn complete_start_with_progress( + &mut self, + session_id: &str, + generation: u64, + progress: Option, + ) -> Result { + let mut session = self.session_for_turn(session_id, generation)?; let context = self.optimize_context( - ContextBundle::from_start(params), + session.context.clone(), &session.original_prompt, &session.context_policy, ); @@ -135,6 +168,7 @@ impl Engine { None, ) .await; + session.interaction_feedback.clear(); let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); let attempts = response.metadata.attempts.clone(); let model = response.metadata.model.clone(); @@ -146,7 +180,7 @@ impl Engine { let token_usage = session.token_usage.clone(); let context_report = session.context.report.clone(); - self.sessions.insert(session_id.clone(), session); + self.commit_session(session, generation)?; self.schedule_prefetch(&session_id).await; self.schedule_goal_continuation(&session_id).await; @@ -172,18 +206,30 @@ impl Engine { action: Action, progress: Option, ) -> Result { - let mut session = self.take_session(session_id)?; + let generation = self.begin_turn(session_id)?; + self.action_with_progress_generation(session_id, generation, action, progress) + .await + } + + pub async fn action_with_progress_generation( + &mut self, + session_id: &str, + generation: u64, + action: Action, + progress: Option, + ) -> Result { + let mut session = self.session_for_turn(session_id, generation)?; if matches!(action, Action::Retry | Action::EditPrompt | Action::Stop) { // These actions abandon the pending slice, so a speculated // continuation built on top of it can only be wasted work. self.cancel_goal_continuation(&mut session).await; + self.cancel_post_accept_prefetch(&mut session).await; } - let prefetched = self.take_prefetch(&mut session, &action).await; let result = self - .action_taken(session_id, &mut session, action, progress, prefetched) + .action_taken(session_id, &mut session, action, progress, None) .await; - self.sessions.insert(session_id.into(), session); + self.commit_session(session, generation)?; if result.is_ok() { self.schedule_prefetch(session_id).await; self.schedule_goal_continuation(session_id).await; @@ -202,15 +248,28 @@ impl Engine { text: String, progress: Option, ) -> Result { - let mut session = self.take_session(session_id)?; + let generation = self.begin_turn(session_id)?; + self.reply_with_progress_generation(session_id, generation, text, progress) + .await + } + + pub async fn reply_with_progress_generation( + &mut self, + session_id: &str, + generation: u64, + text: String, + progress: Option, + ) -> Result { + let mut session = self.session_for_turn(session_id, generation)?; // A reply reworks or replaces whatever is pending, so a speculated // continuation of the current slice is stale. self.cancel_goal_continuation(&mut session).await; + self.cancel_post_accept_prefetch(&mut session).await; let result = self .reply_taken(session_id, &mut session, text, progress) .await; - self.sessions.insert(session_id.into(), session); + self.commit_session(session, generation)?; if result.is_ok() { self.schedule_prefetch(session_id).await; self.schedule_goal_continuation(session_id).await; @@ -253,6 +312,12 @@ impl Engine { }); } + if action == Action::Goal { + session.goal_active = true; + session.goal_paused = false; + session.goal_status = loopbiotic_protocol::GoalStatus::Active; + } + if session.state == SessionState::PatchShown && matches!(action, Action::Retry | Action::EditPrompt) { @@ -298,6 +363,7 @@ impl Engine { prefetched, ) .await; + session.interaction_feedback.clear(); let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); let attempts = response.metadata.attempts.clone(); @@ -331,13 +397,12 @@ impl Engine { } let context = session.context.clone(); - let expected = if session.state == SessionState::PatchExplained { - NextState::GoalWhy - } else if session.continuous_goal { - NextState::GoalLoop - } else { - NextState::Any - }; + if session.goal_active || session.goal_paused { + session.goal_active = false; + session.goal_paused = true; + session.goal_status = loopbiotic_protocol::GoalStatus::Paused; + } + let expected = NextState::Conversation; session.state = SessionState::Thinking; @@ -351,6 +416,7 @@ impl Engine { None, ) .await; + session.interaction_feedback.clear(); let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); let attempts = response.metadata.attempts.clone(); @@ -381,13 +447,26 @@ impl Engine { result: PatchApplyResult, progress: Option, ) -> Result { - let mut session = self.take_session(&result.session_id)?; let session_id = result.session_id.clone(); + let generation = self.begin_turn(&session_id)?; + self.apply_result_with_progress_generation(result, generation, progress) + .await + } + + pub async fn apply_result_with_progress_generation( + &mut self, + result: PatchApplyResult, + generation: u64, + progress: Option, + ) -> Result { + let session_id = result.session_id.clone(); + let mut effective_generation = generation; + let mut session = self.session_for_turn(&session_id, generation)?; let output = self - .apply_result_taken(&mut session, result, progress) + .apply_result_taken(&mut session, result, &mut effective_generation, progress) .await; - self.sessions.insert(session_id.clone(), session); + self.commit_session(session, effective_generation)?; if output.is_ok() { // Once an accepted result surfaces the next slice, speculate on // its continuation. Rejections clear goal_slice_continues, so @@ -402,6 +481,7 @@ impl Engine { &mut self, session: &mut Session, result: PatchApplyResult, + generation: &mut u64, progress: Option, ) -> Result { session.state.require_patch()?; @@ -428,9 +508,13 @@ impl Engine { .extend(completed_step_signatures); session.accepted_patches.extend(result.patch_ids.clone()); session.state = SessionState::Summary; - session.goal_status = loopbiotic_protocol::GoalStatus::NeedsReview; + session.goal_status = if session.goal_active { + loopbiotic_protocol::GoalStatus::NeedsReview + } else { + loopbiotic_protocol::GoalStatus::Idle + }; session.next_step = None; - if session.continuous_goal { + if session.goal_active { if let Some(next) = session.pending_patch_cards.pop_front() { session.state = SessionState::PatchShown; session.goal_status = loopbiotic_protocol::GoalStatus::Active; @@ -460,48 +544,54 @@ impl Engine { .goal_turn_taken( &session_id, session, - BackendAction::User(Action::Next), + BackendAction::User(Action::Goal), progress, speculated, ) .await; } - let changed_files = result.changed_files; - let summary = if changed_files.is_empty() { - "The local patch was applied. Continue only if the goal needs another change." - .into() - } else { - format!( - "Applied the local patch to {}. Continue only if the goal needs another change.", - changed_files - .iter() - .map(|file| file.display().to_string()) - .collect::>() - .join(", ") + let expected = NextState::Conversation; + // Applying the local patch is already complete and must survive a + // cancelled/overridden post-accept model turn. Commit that fact + // before awaiting the read-only continuation, then reserve a new + // generation for the background conversation. + session.state = SessionState::CardShown; + self.commit_session(session.clone(), *generation)?; + *generation = self.begin_turn(&session_id)?; + session.turn_generation = *generation; + session.state = SessionState::Thinking; + let prefetched = self.take_post_accept_prefetch(session).await; + let response = self + .next_distinct_response( + session, + BackendAction::PostAccept, + session.context.clone(), + &expected, + progress, + prefetched, ) - }; - let card = Card::Summary(SummaryCard { - id: session.next_card_id("applied"), - title: "Local step applied".into(), - summary, - changed_files, - next_actions: vec![Action::Next, Action::RunCheck, Action::Stop], - }); - session.cards.push(card.clone()); + .await; + session.interaction_feedback.clear(); + let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); + let attempts = response.metadata.attempts.clone(); + let model = response.metadata.model.clone(); + self.add_usage(session, &response.metadata.token_usage); + let card = self.accept_response(session, response, expected)?; return Ok(ActionResult { session_id, card, goal: goal_progress(session), token_usage: session.token_usage.clone(), - turn_token_usage: TokenUsage::default(), + turn_token_usage, context_report: session.context.report.clone(), - model: None, - attempts: vec![], + model, + attempts, }); } session.rejected_patches.extend(result.patch_ids.clone()); + self.cancel_post_accept_prefetch(session).await; session.pending_patch_cards.clear(); session.goal_slice_continues = false; session.next_step = None; @@ -510,7 +600,7 @@ impl Engine { // explicit Retry action if the user wants a replacement draft. self.cancel_goal_continuation(session).await; - session.state = if session.continuous_goal { + session.state = if session.goal_active { SessionState::GoalLoopFailed } else { SessionState::PatchFailed @@ -556,6 +646,7 @@ impl Engine { let response = self .next_distinct_response(session, action, context, &expected, progress, speculated) .await; + session.interaction_feedback.clear(); let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); let attempts = response.metadata.attempts.clone(); let model = response.metadata.model.clone(); @@ -578,6 +669,41 @@ impl Engine { self.sessions.get(session_id) } + pub fn working_result( + &self, + session_id: &str, + turn_id: &str, + deadline_ms: u64, + ) -> Result { + let session = self + .sessions + .get(session_id) + .ok_or_else(|| anyhow!("unknown session {session_id}"))?; + let card = Card::Working(WorkingCard { + id: format!("c_working_{}", session.cards.len() + 1), + turn_id: turn_id.into(), + title: "Agent still working".into(), + phase: "working".into(), + message: + "The response exceeded its interaction budget and is continuing in the background." + .into(), + elapsed_ms: deadline_ms, + deadline_ms, + actions: vec![Action::CancelTurn, Action::Stop], + }); + + Ok(ActionResult { + session_id: session_id.into(), + card, + goal: goal_progress(session), + token_usage: session.token_usage.clone(), + turn_token_usage: TokenUsage::default(), + context_report: session.context.report.clone(), + model: None, + attempts: vec![], + }) + } + pub fn update_context(&mut self, session_id: &str, context: ContextBundle) -> Result<()> { let (prompt, policy) = self .sessions @@ -625,21 +751,18 @@ impl Engine { let expected_kind = expected_card_kind(session, &action, expected); let allow_goal_completion = matches!(expected, NextState::GoalLoop | NextState::GoalWhy); - let mut card_contract = CardContract { + let card_contract = CardContract { expected_kind, allow_goal_completion, + conversation_only: matches!(expected, NextState::Conversation), ..CardContract::default() }; - if allow_goal_completion && !matches!(expected, NextState::GoalWhy) { - card_contract.max_patch_files = MAX_GOAL_PATCH_FILES; - card_contract.max_hunks_per_patch = MAX_GOAL_HUNKS_PER_PATCH; - card_contract.max_changed_lines = MAX_GOAL_CHANGED_LINES; - } BackendRequest { session: SessionSnapshot { id: session.id.clone(), prompt: session.original_prompt.clone(), + interaction_feedback: session.interaction_feedback.clone(), completed_steps: session.completed_steps.clone(), known_observations: session .known_observations @@ -664,6 +787,25 @@ impl Engine { next_state: NextState, ) -> Result { let mut received = response.card; + match &mut received { + Card::Hypothesis(card) if !card.actions.contains(&Action::Goal) => { + let index = card + .actions + .iter() + .position(|action| *action == Action::Stop) + .unwrap_or(card.actions.len()); + card.actions.insert(index, Action::Goal); + } + Card::Finding(card) if !card.actions.contains(&Action::Goal) => { + let index = card + .actions + .iter() + .position(|action| *action == Action::Stop) + .unwrap_or(card.actions.len()); + card.actions.insert(index, Action::Goal); + } + _ => {} + } if let Card::Patch(patch) = &mut received && !patch.actions.contains(&Action::Why) { @@ -707,10 +849,114 @@ impl Engine { Ok(card) } - fn take_session(&mut self, session_id: &str) -> Result { - self.sessions - .remove(session_id) - .ok_or_else(|| anyhow!("unknown session {session_id}")) + pub fn begin_turn(&mut self, session_id: &str) -> Result { + let session = self + .sessions + .get_mut(session_id) + .ok_or_else(|| anyhow!("unknown session {session_id}"))?; + session.turn_generation += 1; + + Ok(session.turn_generation) + } + + fn session_for_turn(&self, session_id: &str, generation: u64) -> Result { + let session = self + .sessions + .get(session_id) + .ok_or_else(|| anyhow!("unknown session {session_id}"))?; + if session.turn_generation != generation { + return Err(anyhow!("turn {generation} for {session_id} was superseded")); + } + + Ok(session.clone()) + } + + fn commit_session(&mut self, session: Session, generation: u64) -> Result<()> { + let current = self + .sessions + .get(&session.id) + .ok_or_else(|| anyhow!("unknown session {}", session.id))?; + if current.turn_generation != generation { + return Err(anyhow!( + "turn {generation} for {} was superseded", + session.id + )); + } + self.sessions.insert(session.id.clone(), session); + + Ok(()) + } + + pub async fn cancel_turn(&mut self, session_id: &str) -> Result { + let mut session = self + .sessions + .get(session_id) + .cloned() + .ok_or_else(|| anyhow!("unknown session {session_id}"))?; + session.turn_generation += 1; + self.cancel_goal_continuation(&mut session).await; + self.cancel_post_accept_prefetch(&mut session).await; + if session.goal_active { + session.goal_active = false; + session.goal_paused = true; + session.goal_status = loopbiotic_protocol::GoalStatus::Paused; + } + session.state = session + .cards + .last() + .map(SessionState::from_card) + .unwrap_or(SessionState::CardShown); + let accepted_patch_waiting_for_conversation = session.state == SessionState::PatchShown + && session.cards.last().is_some_and(|card| { + let Card::Patch(card) = card else { + return false; + }; + card.patches + .iter() + .all(|patch| session.accepted_patches.contains(&patch.id)) + }); + let card = if accepted_patch_waiting_for_conversation { + session.state = SessionState::CardShown; + let card = Card::Finding(FindingCard { + id: session.next_card_id("accepted_cancelled"), + title: "Local step accepted".into(), + finding: "The patch remains applied. The automatic follow-up was cancelled; send a message or explicitly choose the next step.".into(), + location: None, + annotation: None, + actions: vec![ + Action::Follow, + Action::Fix, + Action::Goal, + Action::RunCheck, + Action::Stop, + ], + }); + session.cards.push(card.clone()); + card + } else { + session.cards.last().cloned().unwrap_or_else(|| { + Card::Error(ErrorCard { + id: session.next_card_id("cancelled"), + title: "Turn cancelled".into(), + message: "Agent thinking was cancelled. Send a message or retry when ready." + .into(), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }) + }) + }; + let result = ActionResult { + session_id: session_id.into(), + card, + goal: goal_progress(&session), + token_usage: session.token_usage.clone(), + turn_token_usage: TokenUsage::default(), + context_report: session.context.report.clone(), + model: None, + attempts: vec![], + }; + self.sessions.insert(session_id.into(), session); + + Ok(result) } fn add_usage(&self, session: &mut Session, usage: &Option) { @@ -721,9 +967,9 @@ impl Engine { } fn expected_start_state(session: &Session) -> NextState { - if session.continuous_goal { - NextState::GoalLoop - } else if session.mode == Mode::Fix { + if session.forced_kind.is_none() && session.mode == Mode::Auto { + NextState::Conversation + } else if matches!(session.mode, Mode::Fix | Mode::Propose) { NextState::Patch } else { NextState::Any @@ -741,6 +987,7 @@ fn expected_card_kind( ) -> Option { match next_state { NextState::Patch => return Some(CardKind::Patch), + NextState::Conversation => return None, NextState::GoalLoop => return None, NextState::GoalWhy => return Some(CardKind::Finding), NextState::Summary | NextState::Finished => return Some(CardKind::Summary), @@ -755,10 +1002,12 @@ fn expected_card_kind( Mode::Auto => None, }), BackendAction::Reply(_) => None, + BackendAction::PostAccept => None, BackendAction::ContractRetry(_) => None, BackendAction::LocationGranted => None, BackendAction::User(action) => match action { Action::Fix => Some(CardKind::Patch), + Action::Goal | Action::CancelTurn => None, Action::OtherLead => Some(CardKind::Hypothesis), Action::Follow | Action::Why | Action::Open | Action::RunCheck | Action::Next => { Some(CardKind::Finding) @@ -800,6 +1049,7 @@ fn card_summary(card: &Card) -> String { Card::Hypothesis(card) => format!("hypothesis: {}", card.claim), Card::Finding(card) => format!("finding: {}", card.finding), Card::Patch(card) => format!("patch: {}", card.explanation), + Card::Working(card) => format!("working: {}", card.message), Card::Choice(card) => format!("choice: {}", card.question), Card::Deny(card) => format!("deny: {}", card.reason), Card::OpenLocation(card) => format!("open_location: {}", card.reason), diff --git a/rust/crates/loopbiotic_harness/src/engine/prefetch.rs b/rust/crates/loopbiotic_harness/src/engine/prefetch.rs index b30c944..5da1423 100644 --- a/rust/crates/loopbiotic_harness/src/engine/prefetch.rs +++ b/rust/crates/loopbiotic_harness/src/engine/prefetch.rs @@ -1,10 +1,9 @@ -//! Speculative work while the user is still reading the current card: the -//! Fix prefetch of the likely next card and the goal-continuation prefetch of -//! the next sliced goal turn. Both are session-keyed background turns whose -//! usage folds into the session totals even when the speculation is wasted. +//! Speculative work while the user is still reading the current card: +//! read-only post-accept conversation for ordinary drafts, and the next small +//! patch only inside an explicitly authorized goal. use anyhow::Result; -use loopbiotic_backends::{BackendAction, BackendRequest, BackendResponse}; +use loopbiotic_backends::{BackendAction, BackendResponse}; use loopbiotic_protocol::Action; use crate::session::Session; @@ -12,26 +11,20 @@ use crate::state::{NextState, SessionState}; use super::Engine; -/// Speculative prefetch of the likely next card. `Fix` requests the patch -/// card in the background while the user is still reading a discovery card, -/// so pressing Fix returns (near-)instantly on a hit. +/// Ordinary speculation is read-only. While a non-goal patch is reviewed, +/// prepare the conversational follow-up that should appear if it is accepted. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PrefetchMode { Off, - Fix, + ReadOnly, } pub(super) struct Prefetch { - action: Action, - fingerprint: u64, handle: tokio::task::JoinHandle>, } -/// A speculative goal-continuation turn: while the user reviews the hunks of -/// one goal slice, the next slice is already being generated on the same -/// backend session. Unlike the Fix prefetch it is not fingerprint-matched — -/// accepting hunks necessarily changes the context, and the consumed response -/// still passes every validation gate against the live buffers. +/// A speculative goal-continuation turn: while the user reviews one explicit +/// goal hunk, the next small hunk is generated on the same backend session. pub(super) struct Continuation { handle: tokio::task::JoinHandle>, } @@ -44,12 +37,10 @@ impl Continuation { } impl Engine { - /// Requests the likely next card in the background while the user reads - /// the one just shown. Only Fix is predicted: it is the most common and - /// slowest follow-up, and on backends with a separate patch process a - /// misprediction never blocks the user's real next request. + /// Prepares the read-only card that follows acceptance of an ordinary + /// draft. Patch speculation is reserved for explicit goals below. pub(super) async fn schedule_prefetch(&mut self, session_id: &str) { - if self.prefetch_mode != PrefetchMode::Fix { + if self.prefetch_mode != PrefetchMode::ReadOnly { return; } @@ -72,83 +63,54 @@ impl Engine { let Some(session) = self.sessions.get(session_id) else { return; }; - if session.state != SessionState::CardShown { + if session.state != SessionState::PatchShown || session.goal_active { return; } - let Some(card) = session.cards.last() else { - return; - }; - if !card.actions().contains(&Action::Fix) { + if !matches!( + session.cards.last(), + Some(loopbiotic_protocol::Card::Patch(_)) + ) { return; } - let Ok(expected) = session.state.next(&Action::Fix) else { - return; - }; let request = self.request( session, - BackendAction::User(Action::Fix), + BackendAction::PostAccept, session.context.clone(), - &expected, + &NextState::Conversation, ); - let fingerprint = request_fingerprint(&request); let backend = self.backend.clone(); let handle = tokio::spawn(async move { backend.next_card(request).await }); - self.prefetches.insert( - session_id.to_string(), - Prefetch { - action: Action::Fix, - fingerprint, - handle, - }, - ); + self.prefetches + .insert(session_id.to_string(), Prefetch { handle }); } - /// Consumes a pending speculation if it was computed for exactly the - /// request this action would produce; otherwise leaves the real path - /// untouched and keeps the wasted tokens accounted for. - pub(super) async fn take_prefetch( + pub(super) async fn take_post_accept_prefetch( &mut self, session: &mut Session, - action: &Action, ) -> Option { let entry = self.prefetches.remove(&session.id)?; - if entry.action == *action - && let Ok(expected) = session.state.next(action) - { - let request = self.request( - session, - BackendAction::User(action.clone()), - session.context.clone(), - &expected, - ); - if request_fingerprint(&request) == entry.fingerprint { - return match entry.handle.await { - Ok(Ok(response)) => Some(response), - _ => None, - }; - } - if entry.handle.is_finished() { - if let Ok(Ok(response)) = entry.handle.await { - fold_usage(session, &response.metadata.token_usage); - } - return None; - } - self.prefetches.insert(session.id.clone(), entry); - return None; + match entry.handle.await { + Ok(Ok(response)) => Some(response), + _ => None, } + } + pub(super) async fn cancel_post_accept_prefetch(&mut self, session: &mut Session) { + let Some(entry) = self.prefetches.remove(&session.id) else { + return; + }; if entry.handle.is_finished() { if let Ok(Ok(response)) = entry.handle.await { fold_usage(session, &response.metadata.token_usage); } - } else { - self.prefetches.insert(session.id.clone(), entry); + return; } - None + entry.handle.abort(); + let _ = self.backend.cancel_turn(&session.id).await; } /// Speculatively requests the next goal slice in the background while the @@ -156,12 +118,10 @@ impl Engine { /// pending slice's plan says more slices follow; consumed by /// `take_goal_continuation` once the review queue drains. pub(super) async fn schedule_goal_continuation(&mut self, session_id: &str) { - self.reap_cancelled_continuations().await; - let Some(session) = self.sessions.get(session_id) else { return; }; - if !session.continuous_goal + if !session.goal_active || !session.goal_slice_continues || session.state != SessionState::PatchShown { @@ -179,7 +139,7 @@ impl Engine { // conversation the slice came from. let request = self.request( session, - BackendAction::User(Action::Next), + BackendAction::User(Action::Goal), session.context.clone(), &NextState::GoalLoop, ); @@ -206,11 +166,9 @@ impl Engine { } } - /// Aborts the speculation after a reject/retry/reply invalidated the slice - /// it continued from. A finished speculation folds its usage into the - /// session totals right away; an unfinished one keeps running detached and - /// is folded once it completes — wasted turns stay visible either way, - /// the same policy as the Fix prefetch. + /// Aborts speculation after reject/retry/reply invalidated the hunk it + /// continued from. Finished work stays accounted; in-flight work is + /// interrupted at the real backend and cannot surface a replacement. pub(super) async fn cancel_goal_continuation(&mut self, session: &mut Session) { let Some(entry) = self.continuations.remove(&session.id) else { return; @@ -223,27 +181,8 @@ impl Engine { return; } - self.cancelled_continuations - .push((session.id.clone(), entry.handle)); - } - - /// Folds cancelled speculations that have since finished into their - /// sessions' token totals; still-running ones stay queued for a later - /// sweep. - async fn reap_cancelled_continuations(&mut self) { - let pending = std::mem::take(&mut self.cancelled_continuations); - - for (session_id, handle) in pending { - if !handle.is_finished() { - self.cancelled_continuations.push((session_id, handle)); - continue; - } - if let Ok(Ok(response)) = handle.await - && let Some(session) = self.sessions.get_mut(&session_id) - { - fold_usage(session, &response.metadata.token_usage); - } - } + entry.handle.abort(); + let _ = self.backend.cancel_turn(&session.id).await; } } @@ -252,21 +191,3 @@ fn fold_usage(session: &mut Session, usage: &Option u64 { - use std::hash::{DefaultHasher, Hash, Hasher}; - - // Only model-visible data may decide whether a speculative response - // matches: the optimizer report (cache counters vary run to run) and raw - // LSP hints are telemetry that backend_context strips before the model - // ever sees them. - let mut request = request.clone(); - request.context.report = None; - request.context.hints = vec![]; - - let mut hasher = DefaultHasher::new(); - serde_json::to_string(&request) - .unwrap_or_default() - .hash(&mut hasher); - hasher.finish() -} diff --git a/rust/crates/loopbiotic_harness/src/engine/tests.rs b/rust/crates/loopbiotic_harness/src/engine/tests.rs index 63e2156..c1584ec 100644 --- a/rust/crates/loopbiotic_harness/src/engine/tests.rs +++ b/rust/crates/loopbiotic_harness/src/engine/tests.rs @@ -16,7 +16,6 @@ use loopbiotic_protocol::{ BackendInfo, Cursor, FilePatch, FindingCard, HypothesisCard, Mode, PatchCard, }; -use super::prefetch::request_fingerprint; use super::*; pub(super) fn params() -> StartSessionParams { @@ -50,6 +49,27 @@ fn editor_context(buffer_text: &str) -> ContextBundle { } } +fn discovery_response(backend: &str) -> BackendResponse { + BackendResponse { + card: Card::Hypothesis(HypothesisCard { + id: "c_discovery".into(), + title: "Ready to collaborate".into(), + claim: "The request is understood; goal execution remains opt-in.".into(), + evidence: None, + next_move: None, + actions: vec![Action::Follow, Action::Fix, Action::Goal, Action::Stop], + }), + raw_output: None, + metadata: BackendMetadata { + backend: backend.into(), + model: None, + token_usage: Some(TokenUsage::estimated(10, 10)), + activities: vec![], + attempts: vec![], + }, + } +} + #[tokio::test(flavor = "multi_thread")] async fn starts_with_hypothesis() { let backend = Arc::new(MockBackend); @@ -75,70 +95,29 @@ async fn rejects_apply_before_patch() { } #[tokio::test(flavor = "multi_thread")] -async fn continuous_goal_reviews_a_complete_batch_without_more_model_turns() { +async fn explicit_goal_rejects_a_multi_hunk_batch() { let backend = Arc::new(BatchGoalBackend::default()); let mut engine = Engine::new(backend.clone()); let mut goal = params(); goal.mode = Mode::Auto; goal.buffer_text = "first\nmiddle\nlast".into(); - let first = engine.start(goal).await.unwrap(); - - let Card::Patch(first_patch) = &first.card else { - panic!("expected first review hunk"); - }; - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - - let result = PatchApplyResult { - session_id: first.session_id, - card_id: first.card.id().into(), - accepted: true, - patch_ids: vec![first_patch.patches[0].id.clone()], - changed_files: vec![PathBuf::from("src/work.ts")], - error: None, - context: editor_context("FIRST\nmiddle\nlast"), - }; - - let second = engine.apply_result(result).await.unwrap(); - - assert!(matches!(second.card, Card::Patch(_))); - assert_eq!(second.turn_token_usage.total_tokens, 0); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - assert_eq!(second.goal.status, loopbiotic_protocol::GoalStatus::Active); - assert_eq!( - engine - .get(&second.session_id) - .unwrap() - .completed_steps - .len(), - 1 - ); + let start = engine.start(goal).await.unwrap(); + assert!(matches!(start.card, Card::Hypothesis(_))); - let Card::Patch(second_patch) = &second.card else { - panic!("expected second review hunk"); - }; - let result = PatchApplyResult { - session_id: second.session_id, - card_id: second.card.id().into(), - accepted: true, - patch_ids: vec![second_patch.patches[0].id.clone()], - changed_files: vec![PathBuf::from("src/work.ts")], - error: None, - context: editor_context("FIRST\nmiddle\nLAST"), - }; - let complete = engine.apply_result(result).await.unwrap(); + let result = engine + .action(&start.session_id, Action::Goal) + .await + .unwrap(); - assert!(matches!(complete.card, Card::Summary(_))); - assert_eq!(complete.turn_token_usage.total_tokens, 0); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - assert_eq!( - complete.goal.status, - loopbiotic_protocol::GoalStatus::Complete - ); - assert_eq!(complete.goal.completed_steps.len(), 2); + assert!(matches!(result.card, Card::Error(_))); + assert!(result.attempts.iter().all(|attempt| { + attempt.violation_class == Some(loopbiotic_protocol::ViolationClass::MultiHunk) + })); + assert!(engine.continuations.is_empty()); } #[tokio::test(flavor = "multi_thread")] -async fn continuous_goal_reviews_a_multi_file_batch_without_more_model_turns() { +async fn explicit_goal_rejects_a_multi_file_batch() { let backend = Arc::new(MultiFileGoalBackend::default()); let reads = Arc::new(AtomicUsize::new(0)); let mut engine = Engine::new(backend.clone()); @@ -161,73 +140,33 @@ async fn continuous_goal_reviews_a_multi_file_batch_without_more_model_turns() { goal.mode = Mode::Auto; goal.buffer_text = "first".into(); - let first = engine.start(goal).await.unwrap(); - let Card::Patch(first_patch) = &first.card else { - panic!( - "expected first file hunk, got {:?}; attempts {:?}", - first.card, first.attempts - ); - }; - assert_eq!(first_patch.patches[0].file, PathBuf::from("src/work.ts")); - assert!(!first_patch.goal_complete); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - assert_eq!(reads.load(Ordering::SeqCst), 2); - assert!( - engine.continuations.is_empty(), - "a legacy full batch must not trigger slice speculation" - ); - - let second = engine - .apply_result(PatchApplyResult { - session_id: first.session_id, - card_id: first.card.id().into(), - accepted: true, - patch_ids: vec![first_patch.patches[0].id.clone()], - changed_files: vec![PathBuf::from("src/work.ts")], - error: None, - context: editor_context("FIRST"), - }) - .await - .unwrap(); - let Card::Patch(second_patch) = &second.card else { - panic!("expected second file hunk"); - }; - assert_eq!(second_patch.patches[0].file, PathBuf::from("src/other.ts")); - assert!(second_patch.goal_complete); - assert_eq!(second.turn_token_usage.total_tokens, 0); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + let start = engine.start(goal).await.unwrap(); + assert!(matches!(start.card, Card::Hypothesis(_))); - let mut other_context = editor_context("OTHER"); - other_context.file = PathBuf::from("src/other.ts"); - let complete = engine - .apply_result(PatchApplyResult { - session_id: second.session_id, - card_id: second.card.id().into(), - accepted: true, - patch_ids: vec![second_patch.patches[0].id.clone()], - changed_files: vec![PathBuf::from("src/other.ts")], - error: None, - context: other_context, - }) + let result = engine + .action(&start.session_id, Action::Goal) .await .unwrap(); - assert!(matches!(complete.card, Card::Summary(_))); - assert_eq!( - complete.goal.status, - loopbiotic_protocol::GoalStatus::Complete - ); - assert_eq!(complete.turn_token_usage.total_tokens, 0); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + assert!(matches!(result.card, Card::Error(_))); + assert!(result.attempts.iter().all(|attempt| { + attempt.violation_class == Some(loopbiotic_protocol::ViolationClass::MultiHunk) + })); + assert_eq!(reads.load(Ordering::SeqCst), 0); + assert!(engine.continuations.is_empty()); } #[tokio::test(flavor = "multi_thread")] -async fn continuous_goal_reject_stops_without_another_model_turn() { +async fn explicit_goal_reject_stops_without_another_model_turn() { let backend = Arc::new(MockBackend); let mut engine = Engine::new(backend); let mut goal = params(); goal.mode = Mode::Auto; - let first = engine.start(goal).await.unwrap(); + let start = engine.start(goal).await.unwrap(); + let first = engine + .action(&start.session_id, Action::Goal) + .await + .unwrap(); let Card::Patch(first_patch) = &first.card else { panic!("expected patch card"); }; @@ -266,7 +205,11 @@ async fn why_explains_and_restores_the_same_pending_hunk() { let mut engine = Engine::new(backend); let mut goal = params(); goal.mode = Mode::Auto; - let first = engine.start(goal).await.unwrap(); + let start = engine.start(goal).await.unwrap(); + let first = engine + .action(&start.session_id, Action::Goal) + .await + .unwrap(); let patch_id = first.card.id().to_string(); let explained = engine.action(&first.session_id, Action::Why).await.unwrap(); @@ -356,6 +299,70 @@ async fn rejected_apply_waits_for_explicit_retry() { assert!(retried.turn_token_usage.total_tokens > 0); } +#[tokio::test(flavor = "multi_thread")] +async fn accepted_non_goal_patch_auto_continues_with_conversation() { + let backend = Arc::new(CountingBackend::default()); + let mut engine = Engine::new(backend.clone()); + let start = engine.start(params()).await.unwrap(); + let patch = engine.action(&start.session_id, Action::Fix).await.unwrap(); + + let result = engine + .apply_result(accept( + &patch.session_id, + &patch.card, + "payload = payload or {}", + )) + .await + .unwrap(); + + assert!(matches!(result.card, Card::Finding(_))); + assert_eq!(result.goal.status, loopbiotic_protocol::GoalStatus::Idle); + assert_eq!( + engine.get(&result.session_id).unwrap().state, + SessionState::CardShown + ); + let calls = backend.calls.lock().unwrap().clone(); + assert!( + calls + .iter() + .any(|call| call.starts_with("progress:PostAccept")), + "accept did not trigger conversational follow-up: {calls:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelling_post_accept_work_keeps_the_patch_accepted() { + let backend = Arc::new(HangingPostAcceptBackend); + let mut engine = Engine::new(backend); + let start = engine.start(params()).await.unwrap(); + let patch = engine.action(&start.session_id, Action::Fix).await.unwrap(); + let session_id = patch.session_id.clone(); + let mut turn = Box::pin(engine.apply_result(accept( + &patch.session_id, + &patch.card, + "payload = payload or {}", + ))); + + assert!( + tokio::time::timeout(std::time::Duration::from_millis(25), &mut turn) + .await + .is_err(), + "post-accept backend unexpectedly completed" + ); + drop(turn); + + let committed = engine.get(&session_id).unwrap(); + assert_eq!(committed.state, SessionState::CardShown); + assert_eq!(committed.accepted_patches, vec!["p_1"]); + + let cancelled = engine.cancel_turn(&session_id).await.unwrap(); + let Card::Finding(card) = cancelled.card else { + panic!("accepted patch was restored as pending"); + }; + assert_eq!(card.title, "Local step accepted"); + assert_eq!(cancelled.goal.status, loopbiotic_protocol::GoalStatus::Idle); +} + #[tokio::test(flavor = "multi_thread")] async fn rejects_apply_result_with_unreported_target_file() { let backend = Arc::new(MockBackend); @@ -501,10 +508,10 @@ async fn start_in_fix_mode_requires_a_patch_card() { panic!("expected error card"); }; - assert!(card.message.contains("expected the next goal patch")); + assert!(card.message.contains("expected patch card")); assert_eq!( engine.get(&result.session_id).unwrap().state, - SessionState::GoalLoopFailed + SessionState::PatchFailed ); let apply_error = engine .action(&result.session_id, Action::Apply) @@ -661,7 +668,11 @@ async fn sliced_goal_speculates_and_consumes_each_next_slice_on_accept() { let mut goal = params(); goal.mode = Mode::Auto; - let first = engine.start(goal).await.unwrap(); + let start = engine.start(goal).await.unwrap(); + let first = engine + .action(&start.session_id, Action::Goal) + .await + .unwrap(); let Card::Patch(first_patch) = &first.card else { panic!( "expected the first slice, got {:?}; attempts {:?}", @@ -730,13 +741,14 @@ async fn sliced_goal_speculates_and_consumes_each_next_slice_on_accept() { assert_eq!(complete.goal.completed_steps.len(), 3); assert_eq!(complete.turn_token_usage.total_tokens, 0); - // One real turn plus two consumed speculations; no user action waited for - // a fresh model turn after the first card. + // One conversational start, one explicitly authorized goal turn, and two + // consumed speculations; no accept action waited for a fresh model turn. let calls = backend.calls.lock().unwrap().clone(); - assert_eq!(calls.len(), 3, "unexpected backend calls: {calls:?}"); + assert_eq!(calls.len(), 4, "unexpected backend calls: {calls:?}"); assert!(calls[0].starts_with("progress:Start")); - assert!(calls[1].starts_with("plain:User(Next)")); - assert!(calls[2].starts_with("plain:User(Next)")); + assert!(calls[1].starts_with("progress:User(Goal)")); + assert!(calls[2].starts_with("plain:User(Goal)")); + assert!(calls[3].starts_with("plain:User(Goal)")); } /// Waits until the session's scheduled continuation turn has finished on the @@ -760,7 +772,11 @@ async fn rejected_slice_cancels_speculation_and_waits_for_explicit_retry() { engine.set_source_context_provider(sliced_goal_provider()); let mut goal = params(); goal.mode = Mode::Auto; - let start = engine.start(goal).await.unwrap(); + let discovery = engine.start(goal).await.unwrap(); + let start = engine + .action(&discovery.session_id, Action::Goal) + .await + .unwrap(); let Card::Patch(first_patch) = &start.card else { panic!("expected the first slice, got {:?}", start.card); }; @@ -789,7 +805,7 @@ async fn rejected_slice_cancels_speculation_and_waits_for_explicit_retry() { assert_eq!(rejected.turn_token_usage, TokenUsage::default()); assert!( rejected.token_usage.total_tokens > usage_after_start, - "the cancelled speculation's usage must fold into the session totals" + "already-finished speculation must fold into the session totals" ); assert!( !engine.continuations.contains_key(&start.session_id), @@ -797,9 +813,10 @@ async fn rejected_slice_cancels_speculation_and_waits_for_explicit_retry() { ); let calls = backend.calls.lock().unwrap().clone(); - assert_eq!(calls.len(), 2, "reject triggered a backend turn: {calls:?}"); + assert_eq!(calls.len(), 3, "reject triggered a backend turn: {calls:?}"); assert!(calls[0].starts_with("progress:Start")); - assert!(calls[1].starts_with("plain:User(Next)")); + assert!(calls[1].starts_with("progress:User(Goal)")); + assert!(calls[2].starts_with("plain:User(Goal)")); let redrafted = engine .action(&start.session_id, Action::Retry) @@ -828,7 +845,11 @@ async fn user_retry_on_a_slice_cancels_speculation_and_stops_speculating() { engine.set_source_context_provider(sliced_goal_provider()); let mut goal = params(); goal.mode = Mode::Auto; - let first = engine.start(goal).await.unwrap(); + let discovery = engine.start(goal).await.unwrap(); + let first = engine + .action(&discovery.session_id, Action::Goal) + .await + .unwrap(); assert!(engine.continuations.contains_key(&first.session_id)); let redrafted = engine @@ -843,6 +864,29 @@ async fn user_retry_on_a_slice_cancels_speculation_and_stops_speculating() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn retry_interrupts_an_inflight_goal_continuation() { + let backend = Arc::new(HangingGoalContinuationBackend::default()); + let mut engine = Engine::new(backend.clone()); + engine.set_source_context_provider(sliced_goal_provider()); + let start = engine.start(params()).await.unwrap(); + let first = engine + .action(&start.session_id, Action::Goal) + .await + .unwrap(); + assert!(matches!(first.card, Card::Patch(_))); + assert!(engine.continuations.contains_key(&first.session_id)); + + let redrafted = engine + .action(&first.session_id, Action::Retry) + .await + .unwrap(); + + assert!(matches!(redrafted.card, Card::Patch(_))); + assert!(backend.cancelled.load(Ordering::SeqCst)); + assert!(!engine.continuations.contains_key(&first.session_id)); +} + #[tokio::test(flavor = "multi_thread")] async fn single_file_goal_without_plan_completes_as_a_batch_of_one() { let backend = Arc::new(SingleFileNoPlanBackend::default()); @@ -851,7 +895,11 @@ async fn single_file_goal_without_plan_completes_as_a_batch_of_one() { goal.mode = Mode::Auto; goal.buffer_text = "first".into(); - let first = engine.start(goal).await.unwrap(); + let discovery = engine.start(goal).await.unwrap(); + let first = engine + .action(&discovery.session_id, Action::Goal) + .await + .unwrap(); let Card::Patch(card) = &first.card else { panic!("expected the whole batch as one card, got {:?}", first.card); }; @@ -875,7 +923,7 @@ async fn single_file_goal_without_plan_completes_as_a_batch_of_one() { complete.goal.status, loopbiotic_protocol::GoalStatus::Complete ); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + assert_eq!(backend.calls.load(Ordering::SeqCst), 2); } #[derive(Default)] @@ -887,6 +935,9 @@ struct SingleFileNoPlanBackend { impl BackendAdapter for SingleFileNoPlanBackend { async fn next_card(&self, req: BackendRequest) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); + if !req.card_contract.allow_goal_completion { + return Ok(discovery_response("single_file_no_plan")); + } assert!(req.card_contract.allow_goal_completion); Ok(BackendResponse { @@ -935,13 +986,22 @@ struct BatchGoalBackend { impl BackendAdapter for BatchGoalBackend { async fn next_card(&self, req: BackendRequest) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); + if !req.card_contract.allow_goal_completion { + return Ok(discovery_response("batch_goal")); + } assert!(req.card_contract.allow_goal_completion); - assert_eq!(req.card_contract.max_patch_files, MAX_GOAL_PATCH_FILES); + assert_eq!( + req.card_contract.max_patch_files, + loopbiotic_protocol::MAX_PATCH_FILES + ); assert_eq!( req.card_contract.max_hunks_per_patch, - MAX_GOAL_HUNKS_PER_PATCH + loopbiotic_protocol::MAX_HUNKS_PER_PATCH + ); + assert_eq!( + req.card_contract.max_changed_lines, + loopbiotic_protocol::MAX_CHANGED_LINES ); - assert_eq!(req.card_contract.max_changed_lines, MAX_GOAL_CHANGED_LINES); Ok(BackendResponse { card: Card::Patch(PatchCard { @@ -984,7 +1044,13 @@ struct MultiFileGoalBackend { impl BackendAdapter for MultiFileGoalBackend { async fn next_card(&self, req: BackendRequest) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); - assert_eq!(req.card_contract.max_patch_files, MAX_GOAL_PATCH_FILES); + if !req.card_contract.allow_goal_completion { + return Ok(discovery_response("multi_file_goal")); + } + assert_eq!( + req.card_contract.max_patch_files, + loopbiotic_protocol::MAX_PATCH_FILES + ); Ok(BackendResponse { card: Card::Patch(PatchCard { @@ -1037,6 +1103,49 @@ struct WrongTypeBackend; struct AlwaysFailBackend; +struct HangingPostAcceptBackend; + +#[async_trait] +impl BackendAdapter for HangingPostAcceptBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + if matches!(req.action, BackendAction::PostAccept) { + return std::future::pending().await; + } + MockBackend.next_card(req).await + } + + fn capabilities(&self) -> BackendInfo { + MockBackend::info() + } +} + +#[derive(Default)] +struct HangingGoalContinuationBackend { + inner: MockBackend, + cancelled: AtomicBool, +} + +#[async_trait] +impl BackendAdapter for HangingGoalContinuationBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + if matches!(req.action, BackendAction::User(Action::Goal)) + && matches!(req.session.last_card, Some(Card::Patch(_))) + { + return std::future::pending().await; + } + self.inner.next_card(req).await + } + + async fn cancel_turn(&self, _session_id: &str) -> Result<()> { + self.cancelled.store(true, Ordering::SeqCst); + Ok(()) + } + + fn capabilities(&self) -> BackendInfo { + MockBackend::info() + } +} + #[derive(Default)] struct RepeatingObservationBackend { calls: std::sync::atomic::AtomicUsize, @@ -1386,102 +1495,86 @@ impl BackendAdapter for CountingBackend { } #[tokio::test(flavor = "multi_thread")] -async fn prefetched_fix_is_consumed_without_a_second_backend_call() { +async fn read_only_post_accept_prefetch_is_consumed_without_another_turn() { let backend = Arc::new(CountingBackend::default()); let mut engine = Engine::new(backend.clone()); - engine.set_prefetch_mode(PrefetchMode::Fix); + engine.set_prefetch_mode(PrefetchMode::ReadOnly); let start = engine.start(params()).await.unwrap(); - assert!(matches!(start.card, Card::Hypothesis(_))); + let patch = engine.action(&start.session_id, Action::Fix).await.unwrap(); + assert!(matches!(patch.card, Card::Patch(_))); + assert!(engine.prefetches.contains_key(&start.session_id)); - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - assert!(matches!(result.card, Card::Patch(_))); + let result = engine + .apply_result(accept( + &patch.session_id, + &patch.card, + "payload = payload or {}", + )) + .await + .unwrap(); + assert!(matches!(result.card, Card::Finding(_))); + assert!(result.turn_token_usage.total_tokens > 0); let calls = backend.calls.lock().unwrap().clone(); - assert_eq!(calls.len(), 2, "unexpected backend calls: {calls:?}"); + assert_eq!(calls.len(), 3, "unexpected backend calls: {calls:?}"); assert!(calls[0].starts_with("progress:Start")); - assert!(calls[1].starts_with("plain:User(Fix)")); + assert!(calls[1].starts_with("progress:User(Fix)")); + assert!(calls[2].starts_with("plain:PostAccept")); } #[tokio::test(flavor = "multi_thread")] -async fn stale_prefetch_is_discarded_after_context_change() { +async fn rejecting_a_patch_cancels_read_only_speculation_without_redrafting() { let backend = Arc::new(CountingBackend::default()); let mut engine = Engine::new(backend.clone()); - engine.set_prefetch_mode(PrefetchMode::Fix); + engine.set_prefetch_mode(PrefetchMode::ReadOnly); let start = engine.start(params()).await.unwrap(); - let context = ContextBundle { - cwd: PathBuf::from("/tmp/project"), - file: PathBuf::from("src/work.ts"), - cursor: Cursor { line: 1, column: 1 }, - selection: None, - buffer_text: "const edited = true".into(), - buffer_start_line: 1, - diagnostics: vec![], - hints: vec![], - artifacts: vec![], - report: None, - }; - engine.update_context(&start.session_id, context).unwrap(); - - let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); - let Card::Patch(card) = result.card else { + let patch = engine.action(&start.session_id, Action::Fix).await.unwrap(); + let Card::Patch(card) = &patch.card else { panic!("expected patch card"); }; + let rejected = engine + .apply_result(PatchApplyResult { + session_id: patch.session_id.clone(), + card_id: patch.card.id().into(), + accepted: false, + patch_ids: vec![card.patches[0].id.clone()], + changed_files: vec![], + error: None, + context: editor_context("placeholder"), + }) + .await + .unwrap(); - // The mock builds the diff from the buffer's first line, so a patch - // produced from the fresh request must reference the edited buffer. - assert!( - card.patches[0].diff.contains("const edited = true"), - "patch was built from stale context: {}", - card.patches[0].diff - ); + assert!(matches!(rejected.card, Card::Error(_))); + assert!(engine.prefetches.is_empty()); let calls = backend.calls.lock().unwrap().clone(); + assert!(calls.len() <= 3, "reject regenerated work: {calls:?}"); assert!( - calls + !calls .iter() - .any(|call| call.starts_with("progress:User(Fix)")), - "real fix call missing: {calls:?}" + .any(|call| call.starts_with("progress:PostAccept")), + "reject started a replacement turn: {calls:?}" ); } -#[tokio::test(flavor = "multi_thread")] -async fn fingerprint_ignores_optimizer_telemetry() { - let backend = Arc::new(CountingBackend::default()); - let mut engine = Engine::new(backend); - engine.set_prefetch_mode(PrefetchMode::Fix); - let start = engine.start(params()).await.unwrap(); - let session = engine.get(&start.session_id).unwrap(); - - let request = engine.request( - session, - BackendAction::User(Action::Fix), - session.context.clone(), - &NextState::Patch, - ); - let mut noisy = request.clone(); - noisy.context.report = Some(loopbiotic_protocol::ContextReport { - enabled: true, - cache_hits: 42, - ..Default::default() - }); - - assert_eq!(request_fingerprint(&request), request_fingerprint(&noisy)); -} +type RecordedExpectation = (Option, String, Vec); #[derive(Default)] struct ExpectationRecorder { inner: MockBackend, - requests: std::sync::Mutex, String)>>, + requests: std::sync::Mutex>, } #[async_trait] impl BackendAdapter for ExpectationRecorder { async fn next_card(&self, req: BackendRequest) -> Result { - self.requests - .lock() - .unwrap() - .push((req.card_contract.expected_kind, req.session.prompt.clone())); + self.requests.lock().unwrap().push(( + req.card_contract.expected_kind, + req.session.prompt.clone(), + req.session.interaction_feedback.clone(), + )); self.inner.next_card(req).await } @@ -1490,6 +1583,35 @@ impl BackendAdapter for ExpectationRecorder { } } +#[tokio::test(flavor = "multi_thread")] +async fn interaction_feedback_is_injected_once_on_the_next_turn() { + let backend = Arc::new(ExpectationRecorder::default()); + let mut engine = Engine::new(backend.clone()); + let start = engine.start(params()).await.unwrap(); + engine + .record_interaction_feedback( + &start.session_id, + "Previous conversation exceeded the interaction deadline.", + ) + .unwrap(); + + engine + .action(&start.session_id, Action::Follow) + .await + .unwrap(); + engine + .action(&start.session_id, Action::OtherLead) + .await + .unwrap(); + + let requests = backend.requests.lock().unwrap().clone(); + assert_eq!( + requests[1].2, + vec!["Previous conversation exceeded the interaction deadline."] + ); + assert!(requests[2].2.is_empty()); +} + #[tokio::test(flavor = "multi_thread")] async fn plain_auto_prompt_expects_any_kind() { let backend = Arc::new(ExpectationRecorder::default()); diff --git a/rust/crates/loopbiotic_harness/src/engine/turn.rs b/rust/crates/loopbiotic_harness/src/engine/turn.rs index c98a79f..fdc7417 100644 --- a/rust/crates/loopbiotic_harness/src/engine/turn.rs +++ b/rust/crates/loopbiotic_harness/src/engine/turn.rs @@ -8,8 +8,7 @@ use loopbiotic_patch::{ PatchCoherence, PatchNormalizer, PatchValidator, violation, violation_class, }; use loopbiotic_protocol::{ - Action, AgentAttempt, Card, ContextBundle, ErrorCard, MAX_GOAL_CHANGED_LINES, - MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, TokenUsage, ViolationClass, + Action, AgentAttempt, Card, ContextBundle, ErrorCard, TokenUsage, ViolationClass, }; use crate::session::Session; @@ -243,7 +242,7 @@ impl Engine { }); } let instruction = if matches!(expected, NextState::GoalLoop) { - "Re-read every affected file with read-only tools and return the corrected patch with the same scope as before (the same file slice plus its plan, or the same complete batch). Context/remove lines must be exact and contiguous in each corresponding source. Use open_location only if a required source cannot be inspected." + "Re-read the affected block with read-only tools and return the corrected patch with the same small one-hunk scope plus its refreshed plan. Context/remove lines must be exact and contiguous. Use open_location only if the required source cannot be inspected." } else { "Rebuild the same step. Source context/remove lines must be exact and contiguous in the supplied buffer; added lines do not replace omitted source context. The resulting local step must remain type-correct without work deferred to a later card. If the change belongs in a different file than the supplied buffer, return an open_location op with that place instead of another patch." }; @@ -304,12 +303,7 @@ impl Engine { validate_one_card(candidate)?; // Correct miscounted hunk headers before the count check rejects them. PatchNormalizer::normalize_hunk_headers(candidate)?; - PatchValidator::validate_card_with_limits( - candidate, - MAX_GOAL_PATCH_FILES, - MAX_GOAL_HUNKS_PER_PATCH, - MAX_GOAL_CHANGED_LINES, - )?; + PatchValidator::validate_card(candidate)?; let Card::Patch(card) = candidate else { // Guarded by the `matches!` early return above; degrade to a // contract failure (retry/rejected card) instead of panicking if diff --git a/rust/crates/loopbiotic_harness/src/engine/validate.rs b/rust/crates/loopbiotic_harness/src/engine/validate.rs index 9844c6e..0314486 100644 --- a/rust/crates/loopbiotic_harness/src/engine/validate.rs +++ b/rust/crates/loopbiotic_harness/src/engine/validate.rs @@ -3,10 +3,7 @@ use anyhow::Result; use loopbiotic_patch::{PatchValidator, violation}; -use loopbiotic_protocol::{ - Card, ContextBundle, MAX_GOAL_CHANGED_LINES, MAX_GOAL_HUNKS_PER_PATCH, MAX_GOAL_PATCH_FILES, - PatchApplyResult, ViolationClass, -}; +use loopbiotic_protocol::{Card, ContextBundle, PatchApplyResult, ViolationClass}; use crate::session::Session; use crate::state::NextState; @@ -29,16 +26,7 @@ pub(super) fn validate_backend_card( } validate_one_card(card)?; - if matches!(next_state, NextState::GoalLoop) { - PatchValidator::validate_card_with_limits( - card, - MAX_GOAL_PATCH_FILES, - MAX_GOAL_HUNKS_PER_PATCH, - MAX_GOAL_CHANGED_LINES, - )?; - } else { - PatchValidator::validate_card(card)?; - } + PatchValidator::validate_card(card)?; validate_patch_target(card, context)?; PatchValidator::validate_card_against_context(card, context)?; next_state @@ -135,6 +123,12 @@ pub(super) fn validate_one_card(card: &Card) -> Result<()> { require_text("file patch explanation", &patch.explanation)?; } } + Card::Working(card) => { + require_text("card title", &card.title)?; + require_text("working turn id", &card.turn_id)?; + require_text("working phase", &card.phase)?; + require_text("working message", &card.message)?; + } Card::Choice(card) => { require_text("card title", &card.title)?; require_text("choice question", &card.question)?; diff --git a/rust/crates/loopbiotic_harness/src/session.rs b/rust/crates/loopbiotic_harness/src/session.rs index fcfeba1..d9bd14f 100644 --- a/rust/crates/loopbiotic_harness/src/session.rs +++ b/rust/crates/loopbiotic_harness/src/session.rs @@ -31,13 +31,19 @@ pub struct Session { pub completed_steps: Vec, pub completed_step_signatures: Vec<(PathBuf, String)>, pub pending_patch_cards: VecDeque, - /// True while the slice under review came from a sliced goal response - /// whose plan says more file slices follow; the engine speculatively - /// requests the next slice and consumes it once the queue drains. + /// True while the explicit goal plan says another small hunk follows; the + /// engine speculatively requests it during local review. pub goal_slice_continues: bool, pub goal_status: GoalStatus, pub next_step: Option, - pub continuous_goal: bool, + /// True only after the user explicitly selected the Goal action. + pub goal_active: bool, + /// A human message pauses goal execution without discarding progress. + pub goal_paused: bool, + /// Monotonic generation used to discard cancelled or superseded turns. + pub turn_generation: u64, + /// Content-free interaction feedback delivered to the next backend turn. + pub interaction_feedback: Vec, pub known_observations: Vec, pub observation_index: HashMap, pub state: SessionState, @@ -51,9 +57,6 @@ impl Session { let context = ContextBundle::from_start(params.clone()); let (forced_kind, prompt) = parse_kind_prefix(¶ms.prompt); - let continuous_goal = - forced_kind.is_none() && matches!(params.mode, Mode::Auto | Mode::Fix); - Self { id: format!("s_{}", Uuid::new_v4().simple()), cwd: params.cwd, @@ -72,9 +75,12 @@ impl Session { completed_step_signatures: vec![], pending_patch_cards: VecDeque::new(), goal_slice_continues: false, - goal_status: GoalStatus::Active, + goal_status: GoalStatus::Idle, next_step: None, - continuous_goal, + goal_active: false, + goal_paused: false, + turn_generation: 0, + interaction_feedback: vec![], known_observations: vec![], observation_index: HashMap::new(), state: SessionState::Thinking, diff --git a/rust/crates/loopbiotic_harness/src/state.rs b/rust/crates/loopbiotic_harness/src/state.rs index 20361f1..1131f77 100644 --- a/rust/crates/loopbiotic_harness/src/state.rs +++ b/rust/crates/loopbiotic_harness/src/state.rs @@ -6,6 +6,7 @@ pub enum SessionState { Idle, Thinking, CardShown, + Working, PatchShown, PatchExplained, PatchFailed, @@ -20,6 +21,7 @@ pub enum SessionState { #[derive(Clone, Debug, Eq, PartialEq)] pub enum NextState { Any, + Conversation, Card, Patch, GoalLoop, @@ -32,11 +34,14 @@ impl SessionState { pub fn next(&self, action: &Action) -> Result { match (self, action) { (Self::CardShown, Action::Follow | Action::Why | Action::OtherLead | Action::Open) => { - Ok(NextState::Card) + Ok(NextState::Conversation) } + (Self::CardShown, Action::Goal) => Ok(NextState::GoalLoop), (Self::CardShown, Action::Retry | Action::EditPrompt) => Ok(NextState::Any), (Self::CardShown, Action::Fix) => Ok(NextState::Patch), (Self::CardShown, Action::Stop) => Ok(NextState::Finished), + (Self::Working, Action::CancelTurn) => Ok(NextState::Conversation), + (Self::Working, Action::Stop) => Ok(NextState::Finished), (Self::PatchShown, Action::Apply | Action::ApplyPatch { .. }) => Ok(NextState::Summary), (Self::PatchShown, Action::Why) => Ok(NextState::GoalWhy), (Self::PatchShown, Action::Retry | Action::EditPrompt) => Ok(NextState::Patch), @@ -50,7 +55,6 @@ impl SessionState { (Self::PatchExplained, Action::Stop) => Ok(NextState::Finished), (Self::GoalLoopFailed, Action::Retry | Action::EditPrompt) => Ok(NextState::GoalLoop), (Self::GoalLoopFailed, Action::Stop) => Ok(NextState::Finished), - (Self::Summary, Action::Next) => Ok(NextState::GoalLoop), (Self::Summary, Action::RunCheck) => Ok(NextState::Card), (Self::Summary, Action::Stop) => Ok(NextState::Finished), (Self::Finished, _) => Err(anyhow!("session is finished")), @@ -61,6 +65,7 @@ impl SessionState { pub fn from_card(card: &Card) -> Self { match card { Card::Patch(_) => Self::PatchShown, + Card::Working(_) => Self::Working, Card::Summary(_) => Self::Summary, Card::Error(_) => Self::CardShown, _ => Self::CardShown, @@ -80,12 +85,31 @@ impl NextState { pub fn validate(&self, card: &Card) -> Result<()> { match (self, card) { (Self::Any, _) => Ok(()), + ( + Self::Conversation, + Card::Hypothesis(_) + | Card::Finding(_) + | Card::Choice(_) + | Card::Deny(_) + | Card::OpenLocation(_) + | Card::Error(_), + ) => Ok(()), + (Self::Conversation, _) => { + Err(anyhow!("conversation turn cannot return code or a summary")) + } (Self::Card, Card::Patch(_)) => Err(anyhow!("patch card is not allowed here")), (Self::Card, Card::Summary(_)) => Err(anyhow!("summary card is not allowed here")), (Self::Card, _) => Ok(()), (Self::Patch, Card::Patch(_)) => Ok(()), (Self::Patch, _) => Err(anyhow!("expected patch card")), - (Self::GoalLoop, Card::Patch(_) | Card::Summary(_) | Card::Choice(_)) => Ok(()), + ( + Self::GoalLoop, + Card::Patch(_) + | Card::Summary(_) + | Card::Choice(_) + | Card::Finding(_) + | Card::Hypothesis(_), + ) => Ok(()), (Self::GoalLoop, _) => Err(anyhow!( "expected the next goal patch, a blocking choice, or a completed goal summary" )), @@ -120,6 +144,8 @@ mod tests { Action::Why, Action::ResumeDraft, Action::Fix, + Action::Goal, + Action::CancelTurn, Action::OtherLead, Action::Apply, apply_patch(), @@ -139,10 +165,11 @@ mod tests { use SessionState as S; let table: Vec<(S, A, N)> = vec![ - (S::CardShown, A::Follow, N::Card), - (S::CardShown, A::Why, N::Card), - (S::CardShown, A::OtherLead, N::Card), - (S::CardShown, A::Open, N::Card), + (S::CardShown, A::Follow, N::Conversation), + (S::CardShown, A::Why, N::Conversation), + (S::CardShown, A::OtherLead, N::Conversation), + (S::CardShown, A::Open, N::Conversation), + (S::CardShown, A::Goal, N::GoalLoop), (S::CardShown, A::Retry, N::Any), (S::CardShown, A::EditPrompt, N::Any), (S::CardShown, A::Fix, N::Patch), @@ -164,7 +191,8 @@ mod tests { (S::GoalLoopFailed, A::Retry, N::GoalLoop), (S::GoalLoopFailed, A::EditPrompt, N::GoalLoop), (S::GoalLoopFailed, A::Stop, N::Finished), - (S::Summary, A::Next, N::GoalLoop), + (S::Working, A::CancelTurn, N::Conversation), + (S::Working, A::Stop, N::Finished), (S::Summary, A::RunCheck, N::Card), (S::Summary, A::Stop, N::Finished), ]; @@ -191,6 +219,7 @@ mod tests { (S::CardShown, apply_patch()), (S::CardShown, A::ResumeDraft), (S::CardShown, A::Next), + (S::CardShown, A::CancelTurn), (S::CardShown, A::RunCheck), (S::PatchShown, A::Follow), (S::PatchShown, A::Fix), @@ -207,6 +236,7 @@ mod tests { (S::Summary, A::Apply), (S::Summary, A::Fix), (S::Summary, A::Retry), + (S::Summary, A::Next), (S::Applying, A::Stop), (S::Applied, A::Stop), (S::Checking, A::Stop), @@ -325,6 +355,19 @@ mod tests { (N::Any, patch_card(), Ok(())), (N::Any, summary_card(), Ok(())), (N::Any, error_card(), Ok(())), + (N::Conversation, hypothesis_card(), Ok(())), + (N::Conversation, finding_card(), Ok(())), + (N::Conversation, choice_card(), Ok(())), + ( + N::Conversation, + patch_card(), + Err("conversation turn cannot return code or a summary"), + ), + ( + N::Conversation, + summary_card(), + Err("conversation turn cannot return code or a summary"), + ), (N::Card, hypothesis_card(), Ok(())), (N::Card, finding_card(), Ok(())), (N::Card, choice_card(), Ok(())), @@ -343,16 +386,8 @@ mod tests { (N::GoalLoop, patch_card(), Ok(())), (N::GoalLoop, summary_card(), Ok(())), (N::GoalLoop, choice_card(), Ok(())), - ( - N::GoalLoop, - finding_card(), - Err("expected the next goal patch"), - ), - ( - N::GoalLoop, - hypothesis_card(), - Err("expected the next goal patch"), - ), + (N::GoalLoop, finding_card(), Ok(())), + (N::GoalLoop, hypothesis_card(), Ok(())), (N::GoalWhy, finding_card(), Ok(())), ( N::GoalWhy, diff --git a/rust/crates/loopbiotic_patch/src/validate.rs b/rust/crates/loopbiotic_patch/src/validate.rs index ae1a0f1..c2002da 100644 --- a/rust/crates/loopbiotic_patch/src/validate.rs +++ b/rust/crates/loopbiotic_patch/src/validate.rs @@ -687,7 +687,7 @@ mod tests { } #[test] - fn goal_batch_accepts_a_formatting_hunk_over_the_review_limit() { + fn work_turns_do_not_bypass_the_review_limit() { let mut diff = "@@ -1,20 +1,20 @@\n".to_string(); for index in 0..20 { diff.push_str(&format!("-line {index}\n")); @@ -712,13 +712,6 @@ mod tests { }); assert!(PatchValidator::validate_card(&card).is_err()); - PatchValidator::validate_card_with_limits( - &card, - 1, - loopbiotic_protocol::MAX_GOAL_HUNKS_PER_PATCH, - loopbiotic_protocol::MAX_GOAL_CHANGED_LINES, - ) - .unwrap(); } #[test] diff --git a/rust/crates/loopbiotic_protocol/src/agent.rs b/rust/crates/loopbiotic_protocol/src/agent.rs index ed763ca..a7fca25 100644 --- a/rust/crates/loopbiotic_protocol/src/agent.rs +++ b/rust/crates/loopbiotic_protocol/src/agent.rs @@ -208,6 +208,7 @@ impl AgentOp { Action::Follow, Action::Why, Action::Fix, + Action::Goal, Action::OtherLead, Action::Stop, ], @@ -227,6 +228,7 @@ impl AgentOp { Action::Open, Action::Why, Action::Fix, + Action::Goal, Action::OtherLead, Action::Stop, ], @@ -314,7 +316,7 @@ impl AgentOp { title, summary, changed_files, - next_actions: vec![Action::Next, Action::RunCheck, Action::Stop], + next_actions: vec![Action::RunCheck, Action::Stop], }), Self::Error { title, message } => Card::Error(ErrorCard { id, diff --git a/rust/crates/loopbiotic_protocol/src/card.rs b/rust/crates/loopbiotic_protocol/src/card.rs index d4c0d92..0135298 100644 --- a/rust/crates/loopbiotic_protocol/src/card.rs +++ b/rust/crates/loopbiotic_protocol/src/card.rs @@ -16,6 +16,7 @@ pub enum CardKind { Hypothesis, Finding, Patch, + Working, Choice, Deny, OpenLocation, @@ -30,6 +31,8 @@ pub enum Action { Why, ResumeDraft, Fix, + Goal, + CancelTurn, OtherLead, Apply, ApplyPatch { patch_id: PatchId }, @@ -47,6 +50,7 @@ pub enum Card { Hypothesis(HypothesisCard), Finding(FindingCard), Patch(PatchCard), + Working(WorkingCard), Choice(ChoiceCard), Deny(DenyCard), OpenLocation(OpenLocationCard), @@ -60,6 +64,7 @@ impl Card { Card::Hypothesis(_) => CardKind::Hypothesis, Card::Finding(_) => CardKind::Finding, Card::Patch(_) => CardKind::Patch, + Card::Working(_) => CardKind::Working, Card::Choice(_) => CardKind::Choice, Card::Deny(_) => CardKind::Deny, Card::OpenLocation(_) => CardKind::OpenLocation, @@ -73,6 +78,7 @@ impl Card { Card::Hypothesis(card) => &card.id, Card::Finding(card) => &card.id, Card::Patch(card) => &card.id, + Card::Working(card) => &card.id, Card::Choice(card) => &card.id, Card::Deny(card) => &card.id, Card::OpenLocation(card) => &card.id, @@ -86,6 +92,7 @@ impl Card { Card::Hypothesis(card) => &card.actions, Card::Finding(card) => &card.actions, Card::Patch(card) => &card.actions, + Card::Working(card) => &card.actions, Card::Choice(_) => &[], Card::Deny(card) => &card.actions, Card::OpenLocation(_) => &[], @@ -129,21 +136,34 @@ pub struct PatchCard { pub explanation: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, - /// The backend prepared every edit required to finish the current goal. - /// The harness may split `patches` into local review cards; only the last - /// queued card retains this flag. + /// This one reviewed hunk finishes the current explicit goal. #[serde(default, skip_serializing_if = "is_false")] pub goal_complete: bool, - /// Sliced goal turns return exactly one file's patch plus this plan of - /// what remains. Absent on legacy full-batch responses. + /// Explicit goal turns return one hunk plus a plan of coherent steps that + /// remain. A later step may target the same file again. #[serde(default, skip_serializing_if = "Option::is_none")] pub plan: Option, pub patches: Vec, pub actions: Vec, } -/// The remainder of a sliced goal: which files still need their own slice and -/// whether the current slice is the final one. +/// A backend turn exceeded its interaction deadline but is still running. +/// The card keeps the editor interactive while the final result is produced +/// in the background. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkingCard { + pub id: CardId, + pub turn_id: String, + pub title: String, + pub phase: String, + pub message: String, + pub elapsed_ms: u64, + pub deadline_ms: u64, + pub actions: Vec, +} + +/// The remainder of an explicit goal: coherent steps still to review and +/// whether the current hunk is the final one. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct GoalPlan { #[serde(default)] diff --git a/rust/crates/loopbiotic_protocol/src/lib.rs b/rust/crates/loopbiotic_protocol/src/lib.rs index 2c89865..b3c513e 100644 --- a/rust/crates/loopbiotic_protocol/src/lib.rs +++ b/rust/crates/loopbiotic_protocol/src/lib.rs @@ -4,7 +4,7 @@ pub mod context; pub mod patch; pub mod rpc; -pub const PROTOCOL_VERSION: u32 = 9; +pub const PROTOCOL_VERSION: u32 = 10; pub use agent::*; pub use card::*; diff --git a/rust/crates/loopbiotic_protocol/src/patch.rs b/rust/crates/loopbiotic_protocol/src/patch.rs index bcc46d4..487db3e 100644 --- a/rust/crates/loopbiotic_protocol/src/patch.rs +++ b/rust/crates/loopbiotic_protocol/src/patch.rs @@ -9,9 +9,6 @@ pub type PatchId = String; pub const MAX_PATCH_FILES: usize = 1; pub const MAX_HUNKS_PER_PATCH: usize = 1; pub const MAX_CHANGED_LINES: usize = 32; -pub const MAX_GOAL_PATCH_FILES: usize = 8; -pub const MAX_GOAL_HUNKS_PER_PATCH: usize = 16; -pub const MAX_GOAL_CHANGED_LINES: usize = 512; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct FilePatch { diff --git a/rust/crates/loopbiotic_protocol/src/rpc.rs b/rust/crates/loopbiotic_protocol/src/rpc.rs index a4c2999..2692e94 100644 --- a/rust/crates/loopbiotic_protocol/src/rpc.rs +++ b/rust/crates/loopbiotic_protocol/src/rpc.rs @@ -203,7 +203,9 @@ pub struct GoalProgress { #[serde(rename_all = "snake_case")] pub enum GoalStatus { #[default] + Idle, Active, + Paused, NeedsReview, Complete, Stopped, diff --git a/rust/crates/loopbioticd/src/main.rs b/rust/crates/loopbioticd/src/main.rs index 7b70211..a4dddbb 100644 --- a/rust/crates/loopbioticd/src/main.rs +++ b/rust/crates/loopbioticd/src/main.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::io::{self, BufRead, Write}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -25,6 +25,9 @@ const READ_FILE_TIMEOUT: Duration = Duration::from_secs(10); /// protocol version that differs from [`loopbiotic_protocol::PROTOCOL_VERSION`]. const PROTOCOL_MISMATCH_CODE: i64 = -32001; static NEXT_EDITOR_REQUEST_ID: AtomicU64 = AtomicU64::new(1); +static NEXT_TURN_ID: AtomicU64 = AtomicU64::new(1); +const DEFAULT_CONVERSATION_DEADLINE: Duration = Duration::from_secs(10); +const DEFAULT_WORK_DEADLINE: Duration = Duration::from_secs(20); #[tokio::main] async fn main() -> Result<()> { @@ -69,19 +72,20 @@ async fn serve_stdio() -> Result<()> { // response; the main loop drains them before reading new lines. let deferred = Arc::new(Mutex::new(VecDeque::::new())); - let mut server = Server::new(backend, progress_reporter(stdout.clone())); - server.engine.set_location_granter(location_granter( - stdout.clone(), - lines.clone(), - deferred.clone(), - )); - server - .engine - .set_source_context_provider(source_context_provider( + let mut server = Server::new(backend, progress_reporter(stdout.clone()), stdout.clone()); + { + let mut engine = server.engine.lock().await; + engine.set_location_granter(location_granter( stdout.clone(), lines.clone(), deferred.clone(), )); + engine.set_source_context_provider(source_context_provider( + stdout.clone(), + lines.clone(), + deferred.clone(), + )); + } loop { let line = { @@ -259,19 +263,74 @@ pub(crate) fn backend_from_env() -> Result> { struct Server { backend: Arc, - engine: Engine, + engine: Arc>, progress: ProgressReporter, + stdout: Arc>, + pending: HashMap, + interaction_feedback: HashMap>, +} + +struct PendingTurn { + turn_id: String, + generation: u64, + abort: tokio::task::AbortHandle, +} + +enum TurnCommand { + Start { + session_id: String, + generation: u64, + }, + Action { + session_id: String, + generation: u64, + action: loopbiotic_protocol::Action, + }, + Reply { + session_id: String, + generation: u64, + text: String, + }, + Apply { + result: Box, + generation: u64, + }, +} + +impl TurnCommand { + fn kind(&self) -> &'static str { + match self { + Self::Start { .. } | Self::Reply { .. } => "conversation", + Self::Action { + action: + loopbiotic_protocol::Action::Fix + | loopbiotic_protocol::Action::Goal + | loopbiotic_protocol::Action::Retry + | loopbiotic_protocol::Action::EditPrompt, + .. + } => "work", + Self::Action { .. } => "conversation", + Self::Apply { .. } => "post_accept", + } + } } impl Server { - fn new(backend: Arc, progress: ProgressReporter) -> Self { + fn new( + backend: Arc, + progress: ProgressReporter, + stdout: Arc>, + ) -> Self { let mut engine = Engine::new(backend.clone()); engine.set_prefetch_mode(prefetch_mode_from_env()); Self { - engine, + engine: Arc::new(tokio::sync::Mutex::new(engine)), backend, progress, + stdout, + pending: HashMap::new(), + interaction_feedback: HashMap::new(), } } @@ -324,66 +383,161 @@ impl Server { "backend/list" => json!([self.backend.capabilities()]), "session/start" => { let params = parse::(&id, request.params)?; - let result = self - .engine - .start_with_progress(params, Some(self.progress.clone())) - .await - .map_err(server_error(&id))?; - - json!(result) + let deadline = start_deadline(¶ms); + let (session_id, generation, working) = { + let mut engine = self.engine.lock().await; + let (session_id, generation) = engine.reserve_start(params); + let turn_id = next_turn_id(); + let working = engine + .working_result(&session_id, &turn_id, deadline.as_millis() as u64) + .map_err(server_error(&id))?; + (session_id, generation, (turn_id, working)) + }; + self.run_turn( + TurnCommand::Start { + session_id: session_id.clone(), + generation, + }, + session_id, + generation, + working, + deadline, + ) + .await + .map_err(server_error(&id))? } "session/action" => { let params = parse::(&id, request.params)?; + self.abort_pending(¶ms.session_id).await; + self.apply_interaction_feedback(¶ms.session_id) + .await + .map_err(server_error(&id))?; + if params.action == loopbiotic_protocol::Action::CancelTurn { + let result = self + .engine + .lock() + .await + .cancel_turn(¶ms.session_id) + .await + .map_err(server_error(&id))?; + return Ok(JsonRpcResponse::ok(id, json!(result))); + } if let Some(context) = params.context { self.engine + .lock() + .await .update_context(¶ms.session_id, context) .map_err(server_error(&id))?; } - let result = self - .engine - .action_with_progress( - ¶ms.session_id, - params.action, - Some(self.progress.clone()), - ) - .await - .map_err(server_error(&id))?; - - json!(result) + let deadline = action_deadline(¶ms.action); + let (generation, working) = { + let mut engine = self.engine.lock().await; + let generation = engine + .begin_turn(¶ms.session_id) + .map_err(server_error(&id))?; + let turn_id = next_turn_id(); + let working = engine + .working_result(¶ms.session_id, &turn_id, deadline.as_millis() as u64) + .map_err(server_error(&id))?; + (generation, (turn_id, working)) + }; + self.run_turn( + TurnCommand::Action { + session_id: params.session_id.clone(), + generation, + action: params.action, + }, + params.session_id, + generation, + working, + deadline, + ) + .await + .map_err(server_error(&id))? } "session/reply" => { let params = parse::(&id, request.params)?; + self.abort_pending(¶ms.session_id).await; + self.apply_interaction_feedback(¶ms.session_id) + .await + .map_err(server_error(&id))?; if let Some(context) = params.context { self.engine + .lock() + .await .update_context(¶ms.session_id, context) .map_err(server_error(&id))?; } - let result = self - .engine - .reply_with_progress( - ¶ms.session_id, - params.text, - Some(self.progress.clone()), - ) - .await - .map_err(server_error(&id))?; - - json!(result) + let (generation, working) = { + let mut engine = self.engine.lock().await; + let generation = engine + .begin_turn(¶ms.session_id) + .map_err(server_error(&id))?; + let turn_id = next_turn_id(); + let working = engine + .working_result( + ¶ms.session_id, + &turn_id, + conversation_deadline().as_millis() as u64, + ) + .map_err(server_error(&id))?; + (generation, (turn_id, working)) + }; + self.run_turn( + TurnCommand::Reply { + session_id: params.session_id.clone(), + generation, + text: params.text, + }, + params.session_id, + generation, + working, + conversation_deadline(), + ) + .await + .map_err(server_error(&id))? } "patch/apply_result" => { let params = parse::(&id, request.params)?; - let result = self - .engine - .apply_result_with_progress(params, Some(self.progress.clone())) + self.abort_pending(¶ms.session_id).await; + self.apply_interaction_feedback(¶ms.session_id) .await .map_err(server_error(&id))?; - - json!(result) + let session_id = params.session_id.clone(); + let deadline = if params.accepted { + Duration::from_millis(1) + } else { + Duration::from_secs(2) + }; + let (generation, working) = { + let mut engine = self.engine.lock().await; + let generation = engine.begin_turn(&session_id).map_err(server_error(&id))?; + let turn_id = next_turn_id(); + let working = engine + .working_result(&session_id, &turn_id, deadline.as_millis() as u64) + .map_err(server_error(&id))?; + (generation, (turn_id, working)) + }; + self.run_turn( + TurnCommand::Apply { + result: Box::new(params), + generation, + }, + session_id, + generation, + working, + deadline, + ) + .await + .map_err(server_error(&id))? } "session/stop" => { let params = parse::(&id, request.params)?; + self.abort_pending(¶ms.session_id).await; let result = self .engine + .lock() + .await .action_with_progress( ¶ms.session_id, loopbiotic_protocol::Action::Stop, @@ -408,12 +562,230 @@ impl Server { Ok(JsonRpcResponse::ok(id, result)) } + + async fn abort_pending(&mut self, session_id: &str) { + if let Some(pending) = self.pending.remove(session_id) { + let finished = pending.abort.is_finished(); + if !finished { + pending.abort.abort(); + if let Err(error) = self.backend.cancel_turn(session_id).await { + eprintln!( + "loopbioticd: failed to cancel backend turn {}: {error:#}", + pending.turn_id + ); + } + } + eprintln!( + "loopbioticd: {} pending turn {} generation {}", + if finished { "reaped" } else { "cancelled" }, + pending.turn_id, + pending.generation + ); + } + } + + async fn apply_interaction_feedback(&mut self, session_id: &str) -> Result<()> { + let feedback = self + .interaction_feedback + .remove(session_id) + .unwrap_or_default(); + if feedback.is_empty() { + return Ok(()); + } + + let mut engine = self.engine.lock().await; + for item in feedback { + engine.record_interaction_feedback(session_id, item)?; + } + + Ok(()) + } + + async fn run_turn( + &mut self, + command: TurnCommand, + session_id: String, + generation: u64, + working: (String, loopbiotic_protocol::ActionResult), + deadline: Duration, + ) -> Result { + let (turn_id, working_result) = working; + let turn_kind = command.kind(); + let engine = self.engine.clone(); + let progress = self.progress.clone(); + let (result_tx, mut result_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let result = execute_turn(engine, command, progress) + .await + .map_err(|error| error.to_string()); + let _ = result_tx.send(result); + }); + let abort = task.abort_handle(); + + tokio::select! { + result = &mut result_rx => { + match result { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(anyhow::anyhow!(error)), + Err(_) => Err(anyhow::anyhow!("turn task stopped before returning a result")), + } + } + _ = tokio::time::sleep(deadline) => { + let deadline_ms = deadline.as_millis() as u64; + self.interaction_feedback + .entry(session_id.clone()) + .or_default() + .push(format!( + "The previous {turn_kind} turn exceeded Loopbiotic's {deadline_ms} ms interaction deadline and yielded control. Keep this response compact and interactive: return one useful answer or one small authorized step before optional investigation." + )); + self.pending.insert(session_id.clone(), PendingTurn { + turn_id: turn_id.clone(), + generation, + abort, + }); + let yielded = JsonRpcNotification { + jsonrpc: "2.0".into(), + method: "agent/turn_yielded".into(), + params: json!({ + "session_id": session_id, + "turn_id": turn_id, + "generation": generation, + "turn_kind": turn_kind, + "deadline_ms": deadline_ms, + }), + }; + if let Err(error) = write_json(&self.stdout, &yielded) { + eprintln!("loopbioticd: failed to write turn-yielded notification: {error}"); + } + let stdout = self.stdout.clone(); + tokio::spawn(async move { + let params = match result_rx.await { + Ok(Ok(result)) => json!({ + "session_id": session_id, + "turn_id": turn_id, + "generation": generation, + "result": result, + }), + Ok(Err(error)) => json!({ + "session_id": session_id, + "turn_id": turn_id, + "generation": generation, + "error": error, + }), + Err(_) => return, + }; + let notification = JsonRpcNotification { + jsonrpc: "2.0".into(), + method: "agent/turn_ready".into(), + params, + }; + if let Err(error) = write_json(&stdout, ¬ification) { + eprintln!("loopbioticd: failed to write turn-ready notification: {error}"); + } + }); + + Ok(json!(working_result)) + } + } + } +} + +async fn execute_turn( + engine: Arc>, + command: TurnCommand, + progress: ProgressReporter, +) -> Result { + let mut engine = engine.lock().await; + match command { + TurnCommand::Start { + session_id, + generation, + } => engine + .complete_start_with_progress(&session_id, generation, Some(progress)) + .await + .map(|result| json!(result)), + TurnCommand::Action { + session_id, + generation, + action, + } => engine + .action_with_progress_generation(&session_id, generation, action, Some(progress)) + .await + .map(|result| json!(result)), + TurnCommand::Reply { + session_id, + generation, + text, + } => engine + .reply_with_progress_generation(&session_id, generation, text, Some(progress)) + .await + .map(|result| json!(result)), + TurnCommand::Apply { result, generation } => engine + .apply_result_with_progress_generation(*result, generation, Some(progress)) + .await + .map(|result| json!(result)), + } +} + +fn next_turn_id() -> String { + format!("t_{}", NEXT_TURN_ID.fetch_add(1, Ordering::Relaxed)) +} + +fn start_deadline(params: &StartSessionParams) -> Duration { + let forced_patch = matches!( + loopbiotic_harness::session::parse_kind_prefix(¶ms.prompt).0, + Some(loopbiotic_protocol::CardKind::Patch) + ); + if forced_patch + || matches!( + params.mode, + loopbiotic_protocol::Mode::Fix | loopbiotic_protocol::Mode::Propose + ) + { + work_deadline() + } else { + conversation_deadline() + } +} + +fn action_deadline(action: &loopbiotic_protocol::Action) -> Duration { + if matches!( + action, + loopbiotic_protocol::Action::Fix + | loopbiotic_protocol::Action::Goal + | loopbiotic_protocol::Action::Retry + | loopbiotic_protocol::Action::EditPrompt + ) { + work_deadline() + } else { + conversation_deadline() + } +} + +fn conversation_deadline() -> Duration { + deadline_from_env( + "LOOPBIOTIC_CONVERSATION_DEADLINE_MS", + DEFAULT_CONVERSATION_DEADLINE, + ) +} + +fn work_deadline() -> Duration { + deadline_from_env("LOOPBIOTIC_WORK_DEADLINE_MS", DEFAULT_WORK_DEADLINE) +} + +fn deadline_from_env(name: &str, default: Duration) -> Duration { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|millis| *millis > 0) + .map(Duration::from_millis) + .unwrap_or(default) } fn prefetch_mode_from_env() -> PrefetchMode { match std::env::var("LOOPBIOTIC_PREFETCH").as_deref() { - Ok("fix") => PrefetchMode::Fix, - _ => PrefetchMode::Off, + Ok("off") => PrefetchMode::Off, + _ => PrefetchMode::ReadOnly, } } diff --git a/rust/crates/loopbioticd/tests/rpc.rs b/rust/crates/loopbioticd/tests/rpc.rs index ea051c6..88be140 100644 --- a/rust/crates/loopbioticd/tests/rpc.rs +++ b/rust/crates/loopbioticd/tests/rpc.rs @@ -6,13 +6,16 @@ use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::time::{Duration, Instant}; +use loopbiotic_patch::{PatchApply, UnifiedDiff}; use loopbiotic_protocol::PROTOCOL_VERSION; use serde_json::{Value, json}; /// Generous per-message deadline so slow CI cannot flake, while a hung daemon /// still fails the test instead of blocking forever. const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); -const REAL_CODEX_RESPONSE_BUDGET: Duration = Duration::from_secs(20); +const REAL_CODEX_CONVERSATION_BUDGET: Duration = Duration::from_secs(11); +const REAL_CODEX_WORK_BUDGET: Duration = Duration::from_secs(21); +const LOCAL_INTERACTION_BUDGET: Duration = Duration::from_secs(2); struct Daemon { child: Child, @@ -34,7 +37,7 @@ impl Daemon { fn spawn_codex() -> Self { let model = - std::env::var("LOOPBIOTIC_REAL_CODEX_MODEL").unwrap_or_else(|_| "gpt-5.6-sol".into()); + std::env::var("LOOPBIOTIC_REAL_CODEX_MODEL").unwrap_or_else(|_| "gpt-5.4-mini".into()); Self::spawn_codex_model(model) } @@ -47,6 +50,8 @@ impl Daemon { .env("LOOPBIOTIC_CODEX_ARGS_JSON", r#"["app-server","--stdio"]"#) .env("LOOPBIOTIC_CODEX_MODEL", model.as_ref()) .env("LOOPBIOTIC_CODEX_EFFORT", "low") + .env("LOOPBIOTIC_CODEX_DISCOVERY_MODEL", "gpt-5.4-mini") + .env("LOOPBIOTIC_CODEX_DISCOVERY_EFFORT", "low") .env("LOOPBIOTIC_TURN_TIMEOUT_SECS", "120") .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -54,6 +59,31 @@ impl Daemon { Self::spawn_command(command) } + fn spawn_codex_with_deadlines(conversation_ms: u64, work_ms: u64) -> Self { + let model = + std::env::var("LOOPBIOTIC_REAL_CODEX_MODEL").unwrap_or_else(|_| "gpt-5.4-mini".into()); + let mut command = Command::new(env!("CARGO_BIN_EXE_loopbioticd")); + command + .arg("--stdio") + .env("LOOPBIOTIC_BACKEND", "codex_app") + .env("LOOPBIOTIC_CODEX_COMMAND", "codex") + .env("LOOPBIOTIC_CODEX_ARGS_JSON", r#"["app-server","--stdio"]"#) + .env("LOOPBIOTIC_CODEX_MODEL", model) + .env("LOOPBIOTIC_CODEX_EFFORT", "low") + .env("LOOPBIOTIC_CODEX_DISCOVERY_MODEL", "gpt-5.4-mini") + .env("LOOPBIOTIC_CODEX_DISCOVERY_EFFORT", "low") + .env("LOOPBIOTIC_TURN_TIMEOUT_SECS", "120") + .env( + "LOOPBIOTIC_CONVERSATION_DEADLINE_MS", + conversation_ms.to_string(), + ) + .env("LOOPBIOTIC_WORK_DEADLINE_MS", work_ms.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + Self::spawn_command(command) + } + fn spawn_command(mut command: Command) -> Self { let mut child = command.spawn().expect("spawn loopbioticd"); let stdin = child.stdin.take().expect("child stdin"); @@ -177,6 +207,57 @@ impl Daemon { } } + fn finish_turn( + &mut self, + response: Value, + timeout: Duration, + context: Option<&Value>, + ) -> Value { + assert!( + response.get("error").is_none(), + "unexpected turn error: {response}" + ); + let result = response["result"].clone(); + if result["card"]["kind"] != json!("working") { + return result; + } + + let turn_id = result["card"]["turn_id"] + .as_str() + .expect("working turn id") + .to_owned(); + let started = Instant::now(); + loop { + let remaining = timeout.saturating_sub(started.elapsed()); + assert!( + !remaining.is_zero(), + "timed out after {timeout:?} waiting for turn {turn_id}" + ); + let message = self.next_message_with_timeout(remaining); + if let Some(method) = message.get("method").and_then(Value::as_str) { + if let Some(request_id) = message.get("id").and_then(Value::as_str) + && matches!(method, "editor/read_file" | "editor/open_location") + { + self.send(&json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": match context { + Some(context) => json!({"granted": true, "context": context}), + None => json!({"granted": false}), + }, + })); + continue; + } + if method == "agent/turn_ready" && message["params"]["turn_id"] == json!(turn_id) { + if let Some(error) = message["params"]["error"].as_str() { + panic!("background turn failed: {error}"); + } + return message["params"]["result"].clone(); + } + } + } + } + fn next_message(&mut self) -> Value { self.next_message_with_timeout(RESPONSE_TIMEOUT) } @@ -337,7 +418,7 @@ fn session_start_returns_first_mock_card() { // The mock backend opens investigations with its hypothesis card. assert_eq!(result["card"]["kind"], json!("hypothesis")); assert_eq!(result["card"]["title"], json!("Payload may be skipped")); - assert_eq!(result["goal"]["status"], json!("active")); + assert_eq!(result["goal"]["status"], json!("idle")); // Following the lead keeps the same session alive and yields the next card. let session_id = result["session_id"] @@ -412,7 +493,7 @@ fn real_codex_auto_proposal_is_fast_and_non_mutating() { response.get("error").is_none(), "unexpected error: {response}" ); - let result = &response["result"]; + let result = daemon.finish_turn(response, Duration::from_secs(120), None); let kind = result["card"]["kind"].as_str().unwrap_or(""); let title = result["card"]["title"].as_str().unwrap_or(""); let message = result["card"]["message"].as_str().unwrap_or(""); @@ -429,8 +510,8 @@ fn real_codex_auto_proposal_is_fast_and_non_mutating() { ); assert!( - elapsed <= REAL_CODEX_RESPONSE_BUDGET, - "proposal took {elapsed:?}; Neovim users must not wait over {REAL_CODEX_RESPONSE_BUDGET:?}" + elapsed <= REAL_CODEX_CONVERSATION_BUDGET, + "proposal took {elapsed:?}; Neovim users must regain control within {REAL_CODEX_CONVERSATION_BUDGET:?}" ); assert!( matches!(kind, "hypothesis" | "finding" | "choice"), @@ -438,6 +519,251 @@ fn real_codex_auto_proposal_is_fast_and_non_mutating() { ); } +/// Full real-agent product gate: a question and reply stay conversational, +/// Fix returns one small draft, and accepting it automatically yields a +/// conversational next card without an intermediate summary. +/// +/// cargo test -p loopbioticd --test rpc \ +/// real_codex_interactive_question_fix_accept_workflow -- --ignored --nocapture +#[test] +#[ignore = "requires an authenticated real Codex CLI"] +fn real_codex_interactive_question_fix_accept_workflow() { + let cwd = std::env::temp_dir().join(format!( + "loopbiotic-real-codex-interactive-{}", + std::process::id() + )); + let source = cwd.join("src/work.ts"); + let original = "export function displayName(first, last) {\n return `${first} ${last}`;\n}\n"; + std::fs::create_dir_all(source.parent().expect("source parent")).expect("create fixture"); + std::fs::write(&source, original).expect("write fixture"); + + let mut daemon = Daemon::spawn_codex(); + let init = daemon.request("1", "initialize", json!({})); + assert!(init.get("error").is_none(), "unexpected error: {init}"); + + let context = json!({ + "cwd": cwd, + "file": "src/work.ts", + "cursor": {"line": 2, "column": 3}, + "selection": null, + "buffer_text": original, + "buffer_start_line": 1, + "diagnostics": [], + "hints": [], + "artifacts": [] + }); + + let started = Instant::now(); + daemon.send(&json!({ + "jsonrpc": "2.0", + "id": "2", + "method": "session/start", + "params": start_session_params_with( + cwd.clone(), + "What does displayName do when last is empty, and what is the smallest sensible behavior change?", + "auto", + original, + ), + })); + let first_response = + daemon.response_for_with_editor_context("2", Duration::from_secs(120), &context); + let first_visible = started.elapsed(); + assert!( + first_visible <= REAL_CODEX_CONVERSATION_BUDGET, + "question held the editor for {first_visible:?}" + ); + let first = daemon.finish_turn(first_response, Duration::from_secs(120), Some(&context)); + eprintln!( + "real Codex question: first_visible={first_visible:?} final_kind={}", + first["card"]["kind"].as_str().unwrap_or("") + ); + assert_conversational(&first, "initial question"); + let session_id = first["session_id"].as_str().expect("session id").to_owned(); + + let reply_started = Instant::now(); + daemon.send(&json!({ + "jsonrpc": "2.0", + "id": "3", + "method": "session/reply", + "params": { + "session_id": session_id, + "text": "Keep it minimal: should it return only first, or preserve a trailing space?", + "context": context, + }, + })); + let reply_response = + daemon.response_for_with_editor_context("3", Duration::from_secs(120), &context); + let reply_visible = reply_started.elapsed(); + assert!( + reply_visible <= REAL_CODEX_CONVERSATION_BUDGET, + "reply held the editor for {reply_visible:?}" + ); + let reply = daemon.finish_turn(reply_response, Duration::from_secs(120), Some(&context)); + eprintln!( + "real Codex reply: first_visible={reply_visible:?} final_kind={}", + reply["card"]["kind"].as_str().unwrap_or("") + ); + assert_conversational(&reply, "follow-up question"); + + let fix_started = Instant::now(); + daemon.send(&json!({ + "jsonrpc": "2.0", + "id": "4", + "method": "session/action", + "params": { + "session_id": session_id, + "action": "fix", + "context": context, + }, + })); + let fix_response = + daemon.response_for_with_editor_context("4", Duration::from_secs(120), &context); + let fix_visible = fix_started.elapsed(); + assert!( + fix_visible <= REAL_CODEX_WORK_BUDGET, + "Fix held the editor for {fix_visible:?}" + ); + let fix = daemon.finish_turn(fix_response, Duration::from_secs(120), Some(&context)); + eprintln!( + "real Codex fix: first_visible={fix_visible:?} final_kind={}", + fix["card"]["kind"].as_str().unwrap_or("") + ); + assert_eq!( + fix["card"]["kind"], + json!("patch"), + "Fix did not draft: {fix}" + ); + assert_eq!( + fix["card"]["patches"].as_array().map(Vec::len), + Some(1), + "Fix must return one file: {fix}" + ); + let patch = &fix["card"]["patches"][0]; + let diff = patch["diff"].as_str().expect("patch diff"); + let parsed = UnifiedDiff::parse(diff).expect("real Codex returned parseable diff"); + assert_eq!(parsed.hunks.len(), 1, "Fix must return one hunk: {diff}"); + let updated = PatchApply::apply_to_text(original, &parsed).expect("apply real Codex patch"); + std::fs::write(&source, &updated).expect("write accepted fixture"); + let updated_context = json!({ + "cwd": cwd, + "file": "src/work.ts", + "cursor": {"line": 2, "column": 3}, + "selection": null, + "buffer_text": updated, + "buffer_start_line": 1, + "diagnostics": [], + "hints": [], + "artifacts": [] + }); + + let accept_started = Instant::now(); + let accept_response = daemon.request( + "5", + "patch/apply_result", + json!({ + "session_id": session_id, + "card_id": fix["card"]["id"], + "accepted": true, + "patch_ids": [patch["id"]], + "changed_files": ["src/work.ts"], + "error": null, + "context": updated_context, + }), + ); + let accept_visible = accept_started.elapsed(); + assert!( + accept_visible <= LOCAL_INTERACTION_BUDGET, + "accept held the editor for {accept_visible:?}" + ); + let after_accept = daemon.finish_turn( + accept_response, + Duration::from_secs(120), + Some(&updated_context), + ); + eprintln!( + "real Codex accept: first_visible={accept_visible:?} final_kind={}", + after_accept["card"]["kind"].as_str().unwrap_or("") + ); + assert_conversational(&after_accept, "post-accept continuation"); + assert_ne!( + after_accept["card"]["kind"], + json!("summary"), + "accept must not insert an intermediate summary" + ); +} + +/// Real cancellation gate with deliberately tiny interaction budgets so the +/// real Codex turn yields a Working card deterministically. +/// +/// cargo test -p loopbioticd --test rpc \ +/// real_codex_working_card_can_interrupt_thinking -- --ignored --nocapture +#[test] +#[ignore = "requires an authenticated real Codex CLI"] +fn real_codex_working_card_can_interrupt_thinking() { + let cwd = std::env::temp_dir().join(format!( + "loopbiotic-real-codex-cancel-{}", + std::process::id() + )); + let source = cwd.join("src/work.ts"); + let buffer = "export const answer = 42;\n"; + std::fs::create_dir_all(source.parent().expect("source parent")).expect("create fixture"); + std::fs::write(&source, buffer).expect("write fixture"); + + let mut daemon = Daemon::spawn_codex_with_deadlines(25, 25); + let init = daemon.request("1", "initialize", json!({})); + assert!(init.get("error").is_none(), "unexpected error: {init}"); + + let (working, elapsed) = daemon.timed_request( + "2", + "session/start", + start_session_params_with( + cwd, + "Inspect this function and explain one possible edge case.", + "auto", + buffer, + ), + ); + assert!( + elapsed < LOCAL_INTERACTION_BUDGET, + "Working card took {elapsed:?}" + ); + assert_eq!(working["result"]["card"]["kind"], json!("working")); + let session_id = working["result"]["session_id"] + .as_str() + .expect("session id") + .to_owned(); + + let cancel_started = Instant::now(); + let cancelled = daemon.request( + "3", + "session/action", + json!({"session_id": session_id, "action": "cancel_turn"}), + ); + let cancel_elapsed = cancel_started.elapsed(); + eprintln!("real Codex cancel: working_visible={elapsed:?} cancel={cancel_elapsed:?}"); + + assert!( + cancel_elapsed < LOCAL_INTERACTION_BUDGET, + "cancellation took {cancel_elapsed:?}" + ); + assert!( + cancelled.get("error").is_none(), + "cancel failed: {cancelled}" + ); + assert_eq!( + cancelled["result"]["card"]["title"], + json!("Turn cancelled") + ); +} + +fn assert_conversational(result: &Value, label: &str) { + let kind = result["card"]["kind"].as_str().unwrap_or(""); + assert!( + matches!(kind, "hypothesis" | "finding" | "choice" | "deny" | "error"), + "{label} returned non-conversational {kind}: {result}" + ); +} + /// Manual real-backend gate for the review contract. Codex generates the /// pending patch, but rejecting it must be a local daemon transition: /// no replacement turn, no turn tokens, and no user-visible wait. @@ -491,7 +817,11 @@ fn real_codex_patch_reject_is_local() { patch_response.get("error").is_none(), "unexpected patch error: {patch_response}" ); - let patch_result = &patch_response["result"]; + let patch_result = daemon.finish_turn( + patch_response, + Duration::from_secs(120), + Some(&editor_context), + ); assert_eq!( patch_result["card"]["kind"], json!("patch"), @@ -537,7 +867,7 @@ fn real_codex_patch_reject_is_local() { ); assert!( - elapsed < Duration::from_secs(2), + elapsed < LOCAL_INTERACTION_BUDGET, "reject took {elapsed:?}; it must not wait for Codex" ); assert_eq!(result["card"]["kind"], json!("error")); diff --git a/scripts/headless-smoke.lua b/scripts/headless-smoke.lua index 3cb804f..bade934 100644 --- a/scripts/headless-smoke.lua +++ b/scripts/headless-smoke.lua @@ -13,7 +13,7 @@ require("loopbiotic").setup({ }, }, }) -assert(config.values.backend.prefetch == "off") +assert(config.values.backend.prefetch == "read_only") assert(config.values.backend.token_budget == 50000) assert(config.values.keymaps.resume ~= config.values.keymaps.draft_retry) assert(config.values.keymaps.resume == "pr") @@ -198,6 +198,8 @@ local patch_card = { } require("loopbiotic.commands").setup() assert(vim.fn.exists(":LoopbioticAssess") == 2) +assert(vim.fn.exists(":LoopbioticGoal") == 2) +assert(vim.fn.exists(":LoopbioticCancel") == 2) state.goal = { statement = long_goal, completed_steps = { "first", "second" }, diff --git a/tests/lua/test_interactivity.lua b/tests/lua/test_interactivity.lua new file mode 100644 index 0000000..a4524a9 --- /dev/null +++ b/tests/lua/test_interactivity.lua @@ -0,0 +1,113 @@ +return function(t) + local card = require("loopbiotic.card") + local config = require("loopbiotic.config") + local diff = require("loopbiotic.diff") + local state = require("loopbiotic.state") + local ui = require("loopbiotic.ui") + + local function mapped(buf, lhs) + for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(buf, "n")) do + if mapping.lhs == lhs then + return true + end + end + local mapping = vim.api.nvim_buf_call(buf, function() + return vim.fn.maparg(lhs, "n", false, true) + end) + return type(mapping) == "table" and mapping.buffer == 1 + end + + local function cleanup() + ui.close(state.card_win) + state.card_win = nil + for _, buf in ipairs({ state.card_buf, state.diff_buf, state.source_buf }) do + if buf and vim.api.nvim_buf_is_valid(buf) then + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end + end + state.reset() + end + + t.test("resume focuses the visible action window", function() + state.reset() + local source = vim.api.nvim_create_buf(false, true) + vim.api.nvim_win_set_buf(0, source) + state.session_id = "s_focus" + state.source_buf = source + state.source_cursor = { 1, 0 } + + card.show({ + id = "c_focus", + kind = "finding", + title = "A finding", + finding = "Keep the editor interactive.", + actions = { "follow", "goal", "stop" }, + }) + t.eq(vim.api.nvim_get_current_buf(), source, "card does not steal focus") + + require("loopbiotic").resume() + + t.eq(vim.api.nvim_get_current_win(), state.card_win, "resume enters card") + t.eq(mapped(state.card_buf, "f"), true, "follow shortcut") + t.eq(mapped(state.card_buf, "G"), true, "goal shortcut") + cleanup() + end) + + t.test("working card exposes a local cancel shortcut", function() + state.reset() + local source = vim.api.nvim_create_buf(false, true) + vim.api.nvim_win_set_buf(0, source) + state.session_id = "s_working" + state.source_buf = source + state.source_cursor = { 1, 0 } + + card.show({ + id = "c_working", + kind = "working", + turn_id = "t_1", + title = "Agent still working", + phase = "reviewing", + message = "Reading one relevant block.", + deadline_ms = 10000, + elapsed_ms = 10000, + actions = { "cancel_turn", "stop" }, + }) + + t.eq(mapped(state.card_buf, "c"), true, "cancel shortcut") + t.eq(mapped(state.card_buf, "q"), true, "stop shortcut") + cleanup() + end) + + t.test("draft control card binds every configured shortcut it prints", function() + state.reset() + local draft = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(draft, 0, -1, false, { "changed" }) + vim.api.nvim_win_set_buf(0, draft) + state.session_id = "s_draft" + state.diff_buf = draft + state.diff_win = vim.api.nvim_get_current_win() + state.diff_cursor = { 1, 0 } + state.diff_first_row = 0 + state.goal = { statement = "Keep review interactive", completed_steps = {} } + + diff.controls({ + id = "c_patch", + kind = "patch", + title = "Small hunk", + explanation = "Change one coherent block.", + actions = { "apply", "why", "retry", "stop" }, + }) + + local keys = config.values.keymaps + for _, lhs in ipairs({ + keys.draft_accept, + keys.draft_reject, + keys.draft_retry, + keys.why, + keys.go_to, + }) do + t.eq(mapped(state.card_buf, lhs), true, "missing draft shortcut " .. lhs) + end + cleanup() + end) +end From 191bdcc90fa34911fc1ca9d7c6c12a826ca35148 Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 21:59:18 +0200 Subject: [PATCH 36/37] fix: make draft transitions tab-safe --- CHANGELOG.md | 6 ++ lua/loopbiotic/card.lua | 5 +- lua/loopbiotic/init.lua | 6 ++ lua/loopbiotic/navigation.lua | 12 ++- lua/loopbiotic/rpc.lua | 3 +- lua/loopbiotic/session.lua | 22 +++-- lua/loopbiotic/ui.lua | 52 ++++++++++- lua/loopbiotic/util.lua | 24 +++++ rust/crates/loopbioticd/tests/rpc.rs | 91 +++++++++++++++++++ scripts/headless-smoke.lua | 6 +- tests/lua/test_interactivity.lua | 131 +++++++++++++++++++++++++++ tests/lua/test_navigation.lua | 38 ++++++++ tests/lua/test_safety.lua | 11 +++ tests/lua/test_session.lua | 16 ++++ 14 files changed, 406 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e956823..db6c108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ The project follows [Semantic Versioning](https://semver.org/). - Resuming an already-visible action card now moves focus into its window, and patch action cards register every configured shortcut they display (accept, reject, retry, why, and go-to) in addition to their single-key aliases. +- Working cards tolerate protocol `null` metadata instead of failing while + rendering their token footer. Sending a message while a draft is open now + restores the source and abandons the preview before conversation starts; + tab navigation never starts from a float, and background-tab action floats + are closed only after their tab becomes current, avoiding a Neovim tabline + use-after-free. - Mechanical model diff wrappers are normalized before validation: CRLF line endings, markdown fences, matching git headers, and unambiguous `./`, `a/`, or `b/` path prefixes. Prose, rename/copy metadata, unmatched fences, and diff --git a/lua/loopbiotic/card.lua b/lua/loopbiotic/card.lua index db40da2..f8b9a94 100644 --- a/lua/loopbiotic/card.lua +++ b/lua/loopbiotic/card.lua @@ -50,6 +50,10 @@ local labels = { function M.show(card, opts) opts = opts or {} + local diff = require("loopbiotic.diff") + if card.kind ~= "patch" and diff.valid_preview() then + diff.restore_source() + end if state.details_card ~= card then state.details_card = card state.details_expanded = false @@ -67,7 +71,6 @@ function M.show(card, opts) end if card.kind == "patch" then - local diff = require("loopbiotic.diff") if diff.valid_preview() then diff.controls(card, opts) return diff --git a/lua/loopbiotic/init.lua b/lua/loopbiotic/init.lua index 98281de..6fdf0af 100644 --- a/lua/loopbiotic/init.lua +++ b/lua/loopbiotic/init.lua @@ -87,6 +87,7 @@ function M.setup(opts) group = group, callback = function() vim.schedule(function() + ui.cleanup_deferred() if state.card and state.session_id and not state.thinking_request_id then card.show(state.card) end @@ -341,6 +342,11 @@ function M.reply(text) state.cancelled_turn_id = state.card.turn_id end + local diff = require("loopbiotic.diff") + if diff.valid_preview() then + diff.restore_source() + end + status.hide() local session_id = state.session_id diff --git a/lua/loopbiotic/navigation.lua b/lua/loopbiotic/navigation.lua index f1b96d2..fc5c5c4 100644 --- a/lua/loopbiotic/navigation.lua +++ b/lua/loopbiotic/navigation.lua @@ -61,12 +61,18 @@ function M.open_location(location) end target_win = target_buf and M.current_tab_window(target_buf) or nil + -- Ex/tab commands issued from a focused float can leave that float as the + -- originating tab's tp_curwin. If a later async render closes it from + -- another tab, Neovim's tabline may dereference the freed window. + local normal = M.normal_window() + if normal ~= vim.api.nvim_get_current_win() then + vim.api.nvim_set_current_win(normal) + end + if target_win then vim.api.nvim_set_current_win(target_win) elseif target_buf and open == "current" then - local win = M.normal_window() - vim.api.nvim_set_current_win(win) - vim.api.nvim_win_set_buf(win, target_buf) + vim.api.nvim_win_set_buf(normal, target_buf) elseif open == "tab" then local existing = target_buf and M.any_window(target_buf) or nil if existing then diff --git a/lua/loopbiotic/rpc.lua b/lua/loopbiotic/rpc.lua index 2fa90f7..62aaa83 100644 --- a/lua/loopbiotic/rpc.lua +++ b/lua/loopbiotic/rpc.lua @@ -307,13 +307,14 @@ end function M.handle(line) local ok, message = pcall(vim.json.decode, line) - if not ok then + if not ok or type(message) ~= "table" then log.write("invalid backend JSON", line) ui.notify("Invalid backend JSON", vim.log.levels.ERROR) return end + message = util.normalize_json_nulls(message) log.event(message.method and not message.id and "rpc_notification" or "rpc_response", message) if message.method and not message.id then diff --git a/lua/loopbiotic/session.lua b/lua/loopbiotic/session.lua index b5e6371..9fd01ee 100644 --- a/lua/loopbiotic/session.lua +++ b/lua/loopbiotic/session.lua @@ -69,15 +69,17 @@ end function M.apply_turn_result(result, opts) opts = opts or {} - state.token_usage = result.token_usage - state.turn_token_usage = result.turn_token_usage - if opts.update_model ~= false then - state.backend_model = result.model or state.backend_model + state.token_usage = type(result.token_usage) == "table" and result.token_usage or nil + state.turn_token_usage = type(result.turn_token_usage) == "table" and result.turn_token_usage or nil + if opts.update_model ~= false and type(result.model) == "string" then + state.backend_model = result.model + end + state.context_report = type(result.context_report) == "table" and result.context_report or nil + log.event("context_optimization", state.context_report or {}) + log.event("agent_attempts", type(result.attempts) == "table" and result.attempts or {}) + if type(result.goal) == "table" then + state.goal = result.goal end - state.context_report = result.context_report - log.event("context_optimization", result.context_report or {}) - log.event("agent_attempts", result.attempts or {}) - state.goal = result.goal or state.goal if opts.track_backend_error == false then state.last_backend_error = nil state.backend_preflight_error = nil @@ -87,7 +89,9 @@ function M.apply_turn_result(result, opts) if result.card and result.card.kind ~= "working" then state.cancelled_turn_id = nil end - require("loopbiotic.card").show(result.card) + if type(result.card) == "table" then + require("loopbiotic.card").show(result.card) + end end return M diff --git a/lua/loopbiotic/ui.lua b/lua/loopbiotic/ui.lua index 21f8f53..6309a67 100644 --- a/lua/loopbiotic/ui.lua +++ b/lua/loopbiotic/ui.lua @@ -2,6 +2,18 @@ local config = require("loopbiotic.config") local M = {} local resize_group = vim.api.nvim_create_augroup("LoopbioticFloatViewport", { clear = true }) +local deferred_closes = {} + +local function normal_window(tab) + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab)) do + local ok, win_config = pcall(vim.api.nvim_win_get_config, win) + if ok and win_config.relative == "" then + return win + end + end + + return nil +end function M.setup_highlights() local highlights = { @@ -19,8 +31,44 @@ function M.setup_highlights() end function M.close(win) - if win and vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) + if not win then + return true + end + if not vim.api.nvim_win_is_valid(win) then + deferred_closes[win] = nil + return true + end + + local tab = vim.api.nvim_win_get_tabpage(win) + if tab ~= vim.api.nvim_get_current_tabpage() then + -- Closing a focused float from another tab can leave tp_curwin pointing + -- at freed memory in Neovim 0.12. Hide it now and close it when that tab + -- becomes current, after first selecting a normal editor window. + deferred_closes[win] = true + pcall(vim.api.nvim_win_set_config, win, { hide = true }) + return false + end + + if vim.api.nvim_get_current_win() == win then + local normal = normal_window(tab) + if not normal then + return false + end + vim.api.nvim_set_current_win(normal) + end + + deferred_closes[win] = nil + return pcall(vim.api.nvim_win_close, win, true) +end + +function M.cleanup_deferred() + local current_tab = vim.api.nvim_get_current_tabpage() + for win in pairs(deferred_closes) do + if not vim.api.nvim_win_is_valid(win) then + deferred_closes[win] = nil + elseif vim.api.nvim_win_get_tabpage(win) == current_tab then + M.close(win) + end end end diff --git a/lua/loopbiotic/util.lua b/lua/loopbiotic/util.lua index 6533270..760a755 100644 --- a/lua/loopbiotic/util.lua +++ b/lua/loopbiotic/util.lua @@ -89,6 +89,30 @@ function M.clamp_cursor(buf, line, column) return { math.min(math.max(line or 1, 1), math.max(count, 1)), math.max(column or 0, 0) } end +-- Neovim preserves JSON null as the truthy userdata vim.NIL. Protocol nulls +-- are optional values, so leaving them in decoded messages makes ordinary +-- `if value then` checks enter table/string code with a userdata instead. +---@param value any +---@return any +function M.normalize_json_nulls(value) + if value == vim.NIL then + return nil + end + if type(value) ~= "table" then + return value + end + + for key, item in pairs(value) do + if item == vim.NIL then + value[key] = nil + else + value[key] = M.normalize_json_nulls(item) + end + end + + return value +end + -- Whether file lies inside root (default: the current working directory). -- Symlinks are resolved when the paths exist. ---@param file string|nil diff --git a/rust/crates/loopbioticd/tests/rpc.rs b/rust/crates/loopbioticd/tests/rpc.rs index 88be140..9f4f419 100644 --- a/rust/crates/loopbioticd/tests/rpc.rs +++ b/rust/crates/loopbioticd/tests/rpc.rs @@ -692,6 +692,97 @@ fn real_codex_interactive_question_fix_accept_workflow() { ); } +/// Real-agent regression for the client path that previously crashed +/// Neovim: ask for a draft, then send a message before accepting or rejecting +/// it. The pending patch must be replaced by conversation, never redrafted +/// implicitly. +/// +/// cargo test -p loopbioticd --test rpc \ +/// real_codex_reply_replaces_pending_draft_conversationally -- --ignored --nocapture +#[test] +#[ignore = "requires an authenticated real Codex CLI"] +fn real_codex_reply_replaces_pending_draft_conversationally() { + let cwd = std::env::temp_dir().join(format!( + "loopbiotic-real-codex-draft-reply-{}", + std::process::id() + )); + let source = cwd.join("src/work.ts"); + let original = "export function displayName(first, last) {\n return `${first} ${last}`;\n}\n"; + std::fs::create_dir_all(source.parent().expect("source parent")).expect("create fixture"); + std::fs::write(&source, original).expect("write fixture"); + + let mut daemon = Daemon::spawn_codex(); + let init = daemon.request("1", "initialize", json!({})); + assert!(init.get("error").is_none(), "unexpected error: {init}"); + + let context = json!({ + "cwd": cwd, + "file": "src/work.ts", + "cursor": {"line": 2, "column": 3}, + "selection": null, + "buffer_text": original, + "buffer_start_line": 1, + "diagnostics": [], + "hints": [], + "artifacts": [] + }); + + let first_response = daemon.request( + "2", + "session/start", + start_session_params_with( + cwd, + "What is the smallest safe behavior when last is empty?", + "auto", + original, + ), + ); + let first = daemon.finish_turn(first_response, Duration::from_secs(120), Some(&context)); + assert_conversational(&first, "initial question"); + let session_id = first["session_id"].as_str().expect("session id").to_owned(); + + let fix_response = daemon.request( + "3", + "session/action", + json!({ + "session_id": session_id, + "action": "fix", + "context": context, + }), + ); + let fix = daemon.finish_turn(fix_response, Duration::from_secs(120), Some(&context)); + assert_eq!( + fix["card"]["kind"], + json!("patch"), + "Fix did not draft: {fix}" + ); + + let reply_started = Instant::now(); + daemon.send(&json!({ + "jsonrpc": "2.0", + "id": "4", + "method": "session/reply", + "params": { + "session_id": session_id, + "text": "Before I apply that draft, explain the tradeoff in one concise response.", + "context": context, + }, + })); + let reply_response = + daemon.response_for_with_editor_context("4", Duration::from_secs(120), &context); + let first_visible = reply_started.elapsed(); + assert!( + first_visible <= REAL_CODEX_CONVERSATION_BUDGET, + "reply over a pending draft held the editor for {first_visible:?}" + ); + let reply = daemon.finish_turn(reply_response, Duration::from_secs(120), Some(&context)); + eprintln!( + "real Codex draft reply: first_visible={first_visible:?} final_kind={}", + reply["card"]["kind"].as_str().unwrap_or("") + ); + assert_conversational(&reply, "reply over pending draft"); +} + /// Real cancellation gate with deliberately tiny interaction budgets so the /// real Codex turn yields a Working card deterministically. /// diff --git a/scripts/headless-smoke.lua b/scripts/headless-smoke.lua index bade934..66f9bd0 100644 --- a/scripts/headless-smoke.lua +++ b/scripts/headless-smoke.lua @@ -253,6 +253,7 @@ state.session_id = "headless-session" card.show(location_card) assert(vim.deep_equal(vim.api.nvim_win_get_cursor(0), { 4, 1 })) local previous_float_win = state.card_win +local previous_float_tab = vim.api.nvim_get_current_tabpage() vim.cmd("tabnew") vim.wait(1000, function() return state.card_win @@ -260,8 +261,11 @@ vim.wait(1000, function() and vim.api.nvim_win_get_tabpage(state.card_win) == vim.api.nvim_get_current_tabpage() end) assert(vim.api.nvim_win_get_tabpage(state.card_win) == vim.api.nvim_get_current_tabpage()) -assert(not vim.api.nvim_win_is_valid(previous_float_win)) +assert(vim.api.nvim_win_is_valid(previous_float_win)) require("loopbiotic.ui").close(state.card_win) +vim.api.nvim_set_current_tabpage(previous_float_tab) +require("loopbiotic.ui").cleanup_deferred() +assert(not vim.api.nvim_win_is_valid(previous_float_win)) state.card_win = nil state.card = nil state.last_card = nil diff --git a/tests/lua/test_interactivity.lua b/tests/lua/test_interactivity.lua index a4524a9..a5ac527 100644 --- a/tests/lua/test_interactivity.lua +++ b/tests/lua/test_interactivity.lua @@ -110,4 +110,135 @@ return function(t) end cleanup() end) + + t.test("reply restores the source and abandons the live draft preview", function() + state.reset() + local loopbiotic = require("loopbiotic") + local rpc = require("loopbiotic.rpc") + local source = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(source, 0, -1, false, { "original" }) + local source_tick = vim.api.nvim_buf_get_changedtick(source) + local draft = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(draft, 0, -1, false, { "changed" }) + local draft_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(draft_win, draft) + local card_buf, card_win = ui.float({ "Draft controls" }, { enter = true }) + + state.session_id = "s_reply_draft" + state.source_buf = source + state.source_cursor = { 1, 0 } + state.card = { id = "c_patch", kind = "patch", actions = { "apply", "why", "retry", "stop" } } + state.card_buf = card_buf + state.card_win = card_win + state.diff_buf = draft + state.diff_win = draft_win + state.diff_source_buf = source + state.diff_source_tick = source_tick + + local previous_thinking = config.values.thinking.enabled + local original_request = rpc.request + local sent + config.values.thinking.enabled = false + rpc.request = function(method, params) + sent = { method = method, params = params } + return 1 + end + + local ok, err = pcall(function() + loopbiotic.reply("Explain the tradeoff before changing this.") + t.eq(sent.method, "session/reply", "reply request") + t.eq(vim.api.nvim_get_current_buf(), source, "source restored") + t.eq(vim.api.nvim_buf_is_valid(draft), false, "draft wiped") + t.eq(state.diff_buf, nil, "preview state cleared") + t.eq(state.card_win, nil, "draft controls closed") + end) + + rpc.request = original_request + config.values.thinking.enabled = previous_thinking + if vim.api.nvim_buf_is_valid(source) then + vim.api.nvim_buf_delete(source, { force = true }) + end + state.reset() + if not ok then + error(err, 0) + end + end) + + t.test("a non-patch result restores any preview left by an async turn", function() + state.reset() + local source = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(source, 0, -1, false, { "original" }) + local draft = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(draft, 0, -1, false, { "changed" }) + local draft_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(draft_win, draft) + local old_card_buf, old_card_win = ui.float({ "Draft controls" }, { enter = true }) + + state.session_id = "s_async_result" + state.source_buf = source + state.source_cursor = { 1, 0 } + state.card_buf = old_card_buf + state.card_win = old_card_win + state.diff_buf = draft + state.diff_win = draft_win + state.diff_source_buf = source + state.diff_source_tick = vim.api.nvim_buf_get_changedtick(source) + + card.show({ + id = "c_finding", + kind = "finding", + title = "Explain before editing", + finding = "The pending draft was superseded by conversation.", + actions = { "fix", "stop" }, + }) + + t.eq(vim.api.nvim_get_current_buf(), source, "source restored") + t.eq(vim.api.nvim_buf_is_valid(draft), false, "draft wiped") + t.eq(state.diff_buf, nil, "preview state cleared") + t.eq(vim.api.nvim_win_is_valid(state.card_win), true, "finding rendered") + + ui.close(state.card_win) + state.card_win = nil + if vim.api.nvim_buf_is_valid(source) then + vim.api.nvim_buf_delete(source, { force = true }) + end + state.reset() + end) + + t.test("background-tab action floats are deferred instead of remotely freed", function() + local origin_tab = vim.api.nvim_get_current_tabpage() + local origin_win = vim.api.nvim_get_current_win() + local draft = vim.api.nvim_create_buf(false, true) + vim.bo[draft].buftype = "nofile" + vim.bo[draft].bufhidden = "wipe" + vim.api.nvim_win_set_buf(origin_win, draft) + local old_buf, old_win = ui.float({ "Draft controls" }, { enter = true }) + + vim.cmd("tabnew") + local new_buf, new_win = ui.render(old_buf, old_win, { "Conversation" }, { enter = false }) + + local ok, err = pcall(function() + t.eq(vim.api.nvim_win_is_valid(old_win), true, "old float remains allocated") + t.eq(vim.api.nvim_tabpage_get_win(origin_tab), old_win, "origin pointer remains valid") + t.eq(vim.api.nvim_win_get_tabpage(new_win), vim.api.nvim_get_current_tabpage(), "new float follows tab") + end) + + ui.close(new_win) + vim.api.nvim_set_current_tabpage(origin_tab) + ui.cleanup_deferred() + t.eq(vim.api.nvim_win_is_valid(old_win), false, "old float closes on its own tab") + t.eq(vim.api.nvim_tabpage_get_win(origin_tab), origin_win, "normal origin window restored") + vim.cmd("tabonly") + local replacement = vim.api.nvim_create_buf(false, true) + vim.api.nvim_win_set_buf(0, replacement) + for _, buf in ipairs({ draft, old_buf, new_buf }) do + if vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_buf_delete(buf, { force = true }) + end + end + + if not ok then + error(err, 0) + end + end) end diff --git a/tests/lua/test_navigation.lua b/tests/lua/test_navigation.lua index ece2807..ea799e5 100644 --- a/tests/lua/test_navigation.lua +++ b/tests/lua/test_navigation.lua @@ -39,4 +39,42 @@ return function(t) vim.cmd("bwipeout!") vim.fn.delete(file) end) + + t.test("tab navigation leaves the origin tab on a normal window", function() + local config = require("loopbiotic.config") + local ui = require("loopbiotic.ui") + local previous_open = config.values.navigation.open + local file = vim.fn.tempname() .. ".ts" + vim.fn.writefile({ "export const answer = 42;" }, file) + + local origin_tab = vim.api.nvim_get_current_tabpage() + local origin_win = vim.api.nvim_get_current_win() + local float_buf, float_win = ui.float({ "Focused action card" }, { enter = true }) + config.values.navigation.open = "tab" + + local ok, err = pcall(function() + t.eq(navigation.open_location({ file = file, line = 1, column = 1 }), true) + t.eq(vim.api.nvim_get_current_tabpage() ~= origin_tab, true, "opened another tab") + t.eq(vim.api.nvim_tabpage_get_win(origin_tab), origin_win, "origin current window") + t.eq(vim.api.nvim_win_is_valid(float_win), true, "float remains valid") + end) + + ui.close(float_win) + vim.api.nvim_set_current_tabpage(origin_tab) + ui.cleanup_deferred() + vim.cmd("tabonly") + if vim.api.nvim_buf_is_valid(float_buf) then + vim.api.nvim_buf_delete(float_buf, { force = true }) + end + local target_buf = vim.fn.bufnr(file) + if target_buf >= 0 and vim.api.nvim_buf_is_valid(target_buf) then + vim.api.nvim_buf_delete(target_buf, { force = true }) + end + vim.fn.delete(file) + config.values.navigation.open = previous_open + + if not ok then + error(err, 0) + end + end) end diff --git a/tests/lua/test_safety.lua b/tests/lua/test_safety.lua index 58ed9b8..12cefea 100644 --- a/tests/lua/test_safety.lua +++ b/tests/lua/test_safety.lua @@ -67,6 +67,17 @@ return function(t) t.eq(tag, "ok") end) + t.test("decoded JSON nulls become ordinary absent Lua values", function() + local decoded = vim.json.decode('{"context_report":null,"location":{"annotation":null},"items":[1,null,3]}') + local normalized = util.normalize_json_nulls(decoded) + + t.eq(normalized.context_report, nil, "top-level null") + t.eq(normalized.location.annotation, nil, "nested null") + t.eq(normalized.items[1], 1, "array prefix") + t.eq(normalized.items[2], nil, "array null") + t.eq(normalized.items[3], 3, "array suffix") + end) + t.test("attempt logs keep violation classes while redacting contract content", function() local sanitized = require("loopbiotic.log").sanitize({ outcome = "contract_retry", diff --git a/tests/lua/test_session.lua b/tests/lua/test_session.lua index 11ef659..0bccfd8 100644 --- a/tests/lua/test_session.lua +++ b/tests/lua/test_session.lua @@ -60,6 +60,22 @@ return function(t) state.reset() end) + t.test("apply_turn_result treats decoded null reports as absent", function() + state.reset() + local result = turn_result() + result.context_report = vim.json.decode("null") + result.model = vim.json.decode("null") + result.goal = vim.json.decode("null") + with_stubbed_show(function(shown) + session.apply_turn_result(result) + t.eq(state.context_report, nil, "context_report") + t.eq(state.backend_model, nil, "backend_model") + t.eq(state.goal, nil, "goal") + t.eq(#shown, 1, "card still shown") + end) + state.reset() + end) + t.test("apply_turn_result can skip the model update (patch results)", function() state.reset() state.backend_model = "existing-model" From 477fc2dfa53dcb2345d907f32c5ed66d67e28f5e Mon Sep 17 00:00:00 2001 From: DorianDevp Date: Thu, 16 Jul 2026 22:53:42 +0200 Subject: [PATCH 37/37] fix: prioritize cursor diagnostics --- CHANGELOG.md | 4 + .../loopbiotic_backends/src/codex_app/mod.rs | 115 +++++++++++- .../src/codex_app/schema.rs | 60 +++++-- rust/crates/loopbiotic_context/src/lib.rs | 1 + rust/crates/loopbiotic_context/src/rank.rs | 90 +++++++++- rust/crates/loopbioticd/tests/rpc.rs | 169 ++++++++++++++++++ 6 files changed, 419 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db6c108..c783b83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ The project follows [Semantic Versioning](https://semver.org/). endings, markdown fences, matching git headers, and unambiguous `./`, `a/`, or `b/` path prefixes. Prose, rename/copy metadata, unmatched fences, and headers naming another file are rejected instead of being silently dropped. +- Cursor-local editor errors remain explicit model context even when their + source line is already in the primary excerpt. Codex receives the diagnostic + text on discovery and patch turns, so a distant warning or deprecation no + longer displaces the error beside the cursor. - Push CI runs again: the workflow triggered on a nonexistent `main` branch. ### Changed diff --git a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs index b7a8700..1bf3a1b 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/mod.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/mod.rs @@ -576,7 +576,8 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { - The code must remain type-correct after this hunk. Never change a field type while deferring its producer/initializer to a later card.\n\ - If a safe step needs unseen references or more changed lines, limit this hunk to self-contained preparation such as adding only the new struct definition.\n\ - Context and remove lines must be exact, contiguous source lines from the supplied buffer; never omit source lines between two context lines.\n\ - - Use only the supplied buffer excerpt. Do not inspect the project or use tools.", + - Use the supplied buffer excerpt as the only patch source. Diagnostics and ranked context are evidence only; never patch a different file or unseen block.\n\ + - Do not inspect the project or use tools.", req.card_contract.max_changed_lines ) } else if post_accept { @@ -587,6 +588,7 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { .into() } else { "- Find only one useful next move, not a plan for the whole solution.\n\ + - Treat an editor diagnostic at or nearest the cursor as the strongest direct signal unless the source disproves it; a distant warning or deprecation must not displace a cursor-local error.\n\ - Inspect the supplied ranked project context first. Use targeted project search only when those fragments are insufficient.\n\ - Do not stop just because the initial excerpt is indirect or missing.\n\ - When the user names a destination or consumer such as a template, API, caller, or renderer, prefer that consumer block as the next location before changing its producer.\n\ @@ -611,7 +613,7 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { - error: {\"op\":\"error\",\"title\":string,\"message\":string}" }; - let ranked_context = if (patch_turn && !goal_loop) || req.context.artifacts.is_empty() { + let ranked_context = if req.context.artifacts.is_empty() { "none".into() } else { req.context @@ -631,19 +633,21 @@ fn prompt(req: &BackendRequest, include_context: bool) -> String { .collect::>() .join("\n") }; + let diagnostics = editor_diagnostics(req); let source_context = if include_context { format!( - "File: {}\nCursor: {}:{}\nBuffer starts at file line: {}\nBuffer excerpt:\n```text\n{}\n```\nRanked project context (read before using tools):\n```text\n{}\n```", + "File: {}\nCursor: {}:{}\nEditor diagnostics (nearest current-file diagnostic first):\n```text\n{}\n```\nBuffer starts at file line: {}\nBuffer excerpt:\n```text\n{}\n```\nRanked project context (read before using tools):\n```text\n{}\n```", req.context.file.display(), req.context.cursor.line, req.context.cursor.column, + diagnostics, req.context.buffer_start_line, req.context.buffer_text, ranked_context, ) } else { - "Source context is unchanged from the preceding turn in this Loopbiotic thread. Reuse that exact buffer and ranked project context.".into() + "Source context is unchanged from the preceding turn in this Loopbiotic thread. Reuse those exact diagnostics, buffer, and ranked project context.".into() }; // Block order is byte-order for the provider prompt cache: the static @@ -689,6 +693,45 @@ Last card: {last} ) } +fn editor_diagnostics(req: &BackendRequest) -> String { + let mut diagnostics = req.context.diagnostics.iter().collect::>(); + diagnostics.sort_by_key(|diagnostic| { + let same_file = diagnostic.file == req.context.file + || diagnostic.file.ends_with(&req.context.file) + || req.context.file.ends_with(&diagnostic.file); + let distance = if same_file { + diagnostic.line.abs_diff(req.context.cursor.line) + } else { + usize::MAX + }; + let severity = match diagnostic.severity.as_str() { + "1" | "error" | "Error" => 0, + "2" | "warning" | "Warning" => 1, + _ => 2, + }; + (!same_file, distance, severity) + }); + + if diagnostics.is_empty() { + return "none".into(); + } + + diagnostics + .into_iter() + .map(|diagnostic| { + format!( + "- {}:{}:{} [severity {}] {}", + diagnostic.file.display(), + diagnostic.line, + diagnostic.column, + diagnostic.severity, + diagnostic.message + ) + }) + .collect::>() + .join("\n") +} + fn debug(message: &str) { if std::env::var("LOOPBIOTIC_DEBUG").is_ok() { eprintln!("loopbiotic codex_app: {message}"); @@ -887,6 +930,70 @@ mod tests { assert!(repeated.contains("Source context is unchanged")); } + #[test] + fn prompt_puts_cursor_local_error_before_distant_deprecation() { + let mut request = request(); + request.context.cursor.line = 259; + request.context.diagnostics = vec![ + loopbiotic_protocol::Diagnostic { + file: "src/main.rs".into(), + line: 165, + column: 16, + severity: "4".into(), + message: "document.write is deprecated".into(), + }, + loopbiotic_protocol::Diagnostic { + file: "src/main.rs".into(), + line: 259, + column: 5, + severity: "1".into(), + message: "URLSearchParams is not assignable to FormData".into(), + }, + ]; + + let rendered = prompt(&request, true); + let local = rendered.find("URLSearchParams").unwrap(); + let distant = rendered.find("document.write").unwrap(); + + assert!(local < distant); + assert!(rendered.contains("nearest the cursor as the strongest direct signal")); + } + + #[test] + fn patch_prompt_receives_diagnostics_and_selected_context() { + let mut request = request(); + request.card_contract.expected_kind = Some(loopbiotic_protocol::CardKind::Patch); + request + .context + .diagnostics + .push(loopbiotic_protocol::Diagnostic { + file: "src/main.rs".into(), + line: 1, + column: 1, + severity: "1".into(), + message: "local type error".into(), + }); + request + .context + .artifacts + .push(loopbiotic_protocol::ContextArtifact { + file: "src/main.rs".into(), + start_line: 1, + end_line: 1, + kind: loopbiotic_protocol::ContextArtifactKind::Diagnostic, + reason: "diagnostic near cursor: local type error".into(), + text: "unique ranked diagnostic source".into(), + estimated_tokens: 12, + score: 300, + }); + + let rendered = prompt(&request, true); + + assert!(rendered.contains("local type error")); + assert!(rendered.contains("unique ranked diagnostic source")); + assert!(rendered.contains("evidence only")); + } + #[test] fn accepted_patch_step_rotates_patch_thread() { let mut request = request(); diff --git a/rust/crates/loopbiotic_backends/src/codex_app/schema.rs b/rust/crates/loopbiotic_backends/src/codex_app/schema.rs index a0eea85..bf5a984 100644 --- a/rust/crates/loopbiotic_backends/src/codex_app/schema.rs +++ b/rust/crates/loopbiotic_backends/src/codex_app/schema.rs @@ -32,16 +32,49 @@ pub(super) fn output_schema(req: &BackendRequest) -> Value { } fn conversation_schema() -> Value { - let mut schema = any_op_schema(); - schema["properties"]["op"]["enum"] = json!([ - "hypothesis", - "finding", - "choice", - "deny", - "open_location", - "error" - ]); - schema + object_schema( + &[ + "op", + "title", + "claim", + "evidence", + "next", + "finding", + "location", + "annotation", + "question", + "options", + "reason", + "message", + ], + json!({ + "op": {"type": "string", "enum": ["hypothesis", "finding", "choice", "deny", "open_location", "error"]}, + "title": {"type": "string"}, + "claim": {"type": ["string", "null"]}, + "evidence": nullable_location_schema(), + "next": nullable_location_schema(), + "finding": {"type": ["string", "null"]}, + "location": nullable_location_schema(), + "annotation": {"type": ["string", "null"]}, + "question": {"type": ["string", "null"]}, + "options": { + "type": ["array", "null"], + "items": object_schema( + &["id", "label", "action"], + json!({ + "id": {"type": "string"}, + "label": {"type": "string"}, + "action": { + "type": "string", + "enum": ["follow", "why", "fix", "goal", "other_lead", "retry", "edit_prompt", "open", "run_check", "stop"] + } + }) + ) + }, + "reason": {"type": ["string", "null"]}, + "message": {"type": ["string", "null"]} + }), + ) } /// Schema for turns without a demanded kind: the agent picks whichever op @@ -358,6 +391,13 @@ mod tests { assert!(ops.contains(&json!("choice"))); assert!(!ops.contains(&json!("patch"))); assert!(!ops.contains(&json!("summary"))); + assert!(schema["properties"].get("patches").is_none()); + assert!(schema["properties"].get("goal_complete").is_none()); + assert!(schema["properties"].get("changed_files").is_none()); + assert!( + serde_json::to_string(&schema).unwrap().len() + < serde_json::to_string(&any_op_schema()).unwrap().len() + ); } #[test] diff --git a/rust/crates/loopbiotic_context/src/lib.rs b/rust/crates/loopbiotic_context/src/lib.rs index f9b8794..39998ed 100644 --- a/rust/crates/loopbiotic_context/src/lib.rs +++ b/rust/crates/loopbiotic_context/src/lib.rs @@ -52,6 +52,7 @@ impl ContextOptimizer { terms: &terms, paths: &paths, current_file: current_file.as_deref(), + cursor_line: context.cursor.line, primary_start_line: context.buffer_start_line, primary_end_line: context .buffer_start_line diff --git a/rust/crates/loopbiotic_context/src/rank.rs b/rust/crates/loopbiotic_context/src/rank.rs index 40b01a6..74928cb 100644 --- a/rust/crates/loopbiotic_context/src/rank.rs +++ b/rust/crates/loopbiotic_context/src/rank.rs @@ -123,6 +123,7 @@ pub(crate) struct CandidateQuery<'a> { pub(crate) terms: &'a [QueryTerm], pub(crate) paths: &'a [QueryPath], pub(crate) current_file: Option<&'a Path>, + pub(crate) cursor_line: usize, pub(crate) primary_start_line: usize, pub(crate) primary_end_line: usize, pub(crate) current_dependencies: &'a [String], @@ -141,6 +142,7 @@ impl ProjectIndex { terms, paths, current_file, + cursor_line, primary_start_line, primary_end_line, current_dependencies, @@ -241,24 +243,37 @@ impl ProjectIndex { continue; } let line_index = diagnostic.line.saturating_sub(1).min(file.lines.len() - 1); - if in_primary(line_index) { - continue; - } let severity_bonus = match diagnostic.severity.as_str() { "1" | "error" | "Error" => 30, "2" | "warning" | "Warning" => 15, _ => 0, }; - let score = 190 + severity_bonus; + let cursor_distance = diagnostic.line.abs_diff(cursor_line); + let cursor_bonus = if is_current_file { + match cursor_distance { + 0..=2 => 80, + 3..=10 => 50, + 11..=30 => 20, + _ => 0, + } + } else { + 0 + }; + let score = 190 + severity_bonus + cursor_bonus; if best .as_ref() .is_none_or(|(_, best_score, _, _)| score > *best_score) { + let proximity = if cursor_bonus > 0 { + format!(" near cursor ({} line(s) away)", cursor_distance) + } else { + String::new() + }; best = Some(( line_index, score, ContextArtifactKind::Diagnostic, - format!("diagnostic: {}", diagnostic.message), + format!("diagnostic{proximity}: {}", diagnostic.message), )); } } @@ -700,7 +715,9 @@ mod tests { use std::fs; use std::path::{Path, PathBuf}; - use loopbiotic_protocol::{ContextArtifactKind, ContextHint, ContextHintKind, ContextPolicy}; + use loopbiotic_protocol::{ + ContextArtifactKind, ContextHint, ContextHintKind, ContextPolicy, Diagnostic, + }; use crate::ContextOptimizer; use crate::test_support::context; @@ -908,4 +925,65 @@ mod tests { assert!(remote.text.contains("--text-vw-h3--font-weight")); let _ = fs::remove_dir_all(root); } + + #[test] + fn cursor_local_error_outranks_distant_deprecation_in_primary_buffer() { + let root = std::env::temp_dir().join(format!( + "loopbiotic-context-local-diagnostic-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("static")).unwrap(); + let mut lines = vec!["// filler"; 300]; + lines[164] = "document.write(html);"; + lines[258] = "body = new URLSearchParams(formData);"; + fs::write(root.join("static/admin.js"), lines.join("\n")).unwrap(); + + let mut input = context(&root, &lines[234..270].join("\n")); + input.file = PathBuf::from("static/admin.js"); + input.buffer_start_line = 235; + input.cursor.line = 259; + input.cursor.column = 16; + input.diagnostics = vec![ + Diagnostic { + file: PathBuf::from("static/admin.js"), + line: 259, + column: 5, + severity: "1".into(), + message: "Type 'URLSearchParams' is not assignable to type 'FormData'.".into(), + }, + Diagnostic { + file: PathBuf::from("static/admin.js"), + line: 165, + column: 16, + severity: "4".into(), + message: "The signature of 'document.write' is deprecated.".into(), + }, + ]; + + let mut optimizer = ContextOptimizer::default(); + let optimized = + optimizer.optimize(input, "What's wrong with it?", &ContextPolicy::default()); + + let diagnostic = optimized + .artifacts + .iter() + .find(|artifact| artifact.kind == ContextArtifactKind::Diagnostic) + .expect("cursor-local diagnostic should remain explicit context"); + assert_eq!(diagnostic.start_line, 254); + assert!(diagnostic.reason.contains("near cursor")); + assert!(diagnostic.reason.contains("URLSearchParams")); + assert!(!diagnostic.reason.contains("document.write")); + assert_eq!(diagnostic.score, 300); + let report = optimized.report.unwrap(); + assert!( + report.candidates.iter().any(|candidate| { + candidate.selected + && candidate.kind == ContextArtifactKind::Diagnostic + && candidate.reason.contains("URLSearchParams") + }), + "telemetry should report the local error as delivered" + ); + let _ = fs::remove_dir_all(root); + } } diff --git a/rust/crates/loopbioticd/tests/rpc.rs b/rust/crates/loopbioticd/tests/rpc.rs index 9f4f419..15b31aa 100644 --- a/rust/crates/loopbioticd/tests/rpc.rs +++ b/rust/crates/loopbioticd/tests/rpc.rs @@ -519,6 +519,175 @@ fn real_codex_auto_proposal_is_fast_and_non_mutating() { ); } +/// Real-agent regression for cursor intent: an error at the cursor must beat a +/// distant deprecation in the same file, even though the error's source line is +/// already present in the primary editor excerpt. +/// +/// cargo test -p loopbioticd --test rpc \ +/// real_codex_prioritizes_cursor_local_error_over_distant_deprecation -- --ignored --nocapture +#[test] +#[ignore = "requires an authenticated real Codex CLI"] +fn real_codex_prioritizes_cursor_local_error_over_distant_deprecation() { + let cwd = std::env::temp_dir().join(format!( + "loopbiotic-real-codex-local-diagnostic-{}", + std::process::id() + )); + let source = cwd.join("static/admin.js"); + std::fs::create_dir_all(source.parent().expect("source parent")).expect("create fixture"); + let mut lines = (1..=300) + .map(|line| format!("// filler {line}")) + .collect::>(); + lines[164] = "document.write(html);".into(); + lines[252] = "const formData = new FormData(form);".into(); + lines[253] = "let body = formData;".into(); + lines[258] = "body = new URLSearchParams(formData);".into(); + lines[262] = "return navigate(form.action, { method: form.method, body });".into(); + std::fs::write(&source, lines.join("\n")).expect("write fixture"); + let excerpt = lines[234..270].join("\n"); + + let context = json!({ + "cwd": cwd, + "file": "static/admin.js", + "cursor": {"line": 259, "column": 16}, + "selection": null, + "buffer_text": excerpt, + "buffer_start_line": 235, + "diagnostics": [ + { + "file": "static/admin.js", + "line": 259, + "column": 5, + "severity": "1", + "message": "Type 'URLSearchParams' is not assignable to type 'FormData'. Types of property 'append' are incompatible." + }, + { + "file": "static/admin.js", + "line": 165, + "column": 16, + "severity": "4", + "message": "The signature of 'document.write' is deprecated." + } + ], + "hints": [] + }); + let params = json!({ + "cwd": cwd, + "file": "static/admin.js", + "cursor": {"line": 259, "column": 16}, + "selection": null, + "prompt": "What's wrong with it?", + "mode": "auto", + "buffer_text": excerpt, + "buffer_start_line": 235, + "diagnostics": context["diagnostics"], + "hints": [] + }); + + let mut daemon = Daemon::spawn_codex(); + let init = daemon.request("1", "initialize", json!({})); + assert!(init.get("error").is_none(), "unexpected error: {init}"); + + daemon.send(&json!({ + "jsonrpc": "2.0", + "id": "2", + "method": "session/start", + "params": params, + })); + let response = daemon.response_for_with_editor_context("2", Duration::from_secs(120), &context); + let result = daemon.finish_turn(response, Duration::from_secs(120), Some(&context)); + assert_conversational(&result, "cursor-local diagnostic"); + + let card_text = result["card"].to_string().to_lowercase(); + let location_line = result["card"]["location"]["line"] + .as_u64() + .or_else(|| result["card"]["next"]["line"].as_u64()) + .unwrap_or_default(); + let input = result["turn_token_usage"]["input_tokens"] + .as_u64() + .unwrap_or_default(); + let cached = result["turn_token_usage"]["cached_input_tokens"] + .as_u64() + .unwrap_or_default(); + eprintln!( + "real Codex local diagnostic: location={location_line} input={input} cached={cached} fresh={}", + input.saturating_sub(cached) + ); + + assert!( + card_text.contains("urlsearchparams") + || card_text.contains("formdata") + || card_text.contains("request body"), + "agent missed the cursor-local type error: {result}" + ); + assert!( + (250..=265).contains(&location_line), + "agent navigated away from the cursor-local error: {result}" + ); + assert!( + result["context_report"]["candidates"] + .as_array() + .is_some_and(|candidates| candidates.iter().any(|candidate| { + candidate["selected"] == json!(true) + && candidate["reason"] + .as_str() + .is_some_and(|reason| reason.contains("URLSearchParams")) + })), + "optimizer did not deliver the cursor-local diagnostic: {result}" + ); + + let session_id = result["session_id"].as_str().expect("session id"); + daemon.send(&json!({ + "jsonrpc": "2.0", + "id": "3", + "method": "session/action", + "params": { + "session_id": session_id, + "action": "fix", + "context": context, + }, + })); + let fix_response = + daemon.response_for_with_editor_context("3", Duration::from_secs(120), &context); + let fix = daemon.finish_turn(fix_response, Duration::from_secs(120), Some(&context)); + assert_eq!( + fix["card"]["kind"], + json!("patch"), + "Fix did not draft: {fix}" + ); + let diff = fix["card"]["patches"][0]["diff"] + .as_str() + .expect("real Codex patch diff"); + let parsed = UnifiedDiff::parse(diff).expect("real Codex returned parseable diff"); + assert_eq!(parsed.hunks.len(), 1, "Fix must return one hunk: {diff}"); + let patch_text = diff.to_lowercase(); + let fix_input = fix["turn_token_usage"]["input_tokens"] + .as_u64() + .unwrap_or_default(); + let fix_cached = fix["turn_token_usage"]["cached_input_tokens"] + .as_u64() + .unwrap_or_default(); + eprintln!( + "real Codex local fix: old_start={} input={fix_input} cached={fix_cached} fresh={}", + parsed.hunks[0].old_start, + fix_input.saturating_sub(fix_cached) + ); + + assert!( + (250..=265).contains(&parsed.hunks[0].old_start), + "Fix patched away from the cursor-local error: {diff}" + ); + assert!( + patch_text.contains("formdata") + || patch_text.contains("urlsearchparams") + || patch_text.contains("bodyinit"), + "Fix ignored the type-error block: {diff}" + ); + assert!( + !patch_text.contains("document.write"), + "Fix returned to the distant deprecation: {diff}" + ); +} + /// Full real-agent product gate: a question and reply stay conversational, /// Fix returns one small draft, and accepting it automatically yields a /// conversational next card without an intermediate summary.