From fee6b7858a9d1c7b1de69db297d2ea9b807be514 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 8 Apr 2026 11:34:40 +0200 Subject: [PATCH] refactor: restructured chunk region API and storage layer for consistency - Renamed ChunkRegion::Inner variant to Body across Rust and TypeScript for clearer semantics. - Renamed chunk edit tool parameter from target to sel and made op required instead of optional. - Removed region aliases (prologue, epilogue, inner) and simplified region validation to accept only head, body, tail. - Replaced db.exec() calls with db.run() for SQL execution in auth-storage, agent-storage, and history-storage. - Enhanced chunk edit documentation with comprehensive examples, indentation rules, and CRC refresh guidance. - Improved chunk statement merging logic to consolidate leaf statements into parent function bodies by default. --- crates/pi-natives/src/chunk/ast_python.rs | 14 +- crates/pi-natives/src/chunk/common.rs | 2 +- crates/pi-natives/src/chunk/edit.rs | 245 ++++++++++++++++-- crates/pi-natives/src/chunk/indent.rs | 3 +- crates/pi-natives/src/chunk/mod.rs | 64 ++++- crates/pi-natives/src/chunk/resolve.rs | 35 +-- crates/pi-natives/src/chunk/types.rs | 6 +- packages/ai/src/auth-storage.ts | 26 +- .../ai/test/tool-argument-coercion.test.ts | 4 +- packages/coding-agent/CHANGELOG.md | 18 +- packages/coding-agent/src/edit/modes/chunk.ts | 33 +-- packages/coding-agent/src/edit/renderer.ts | 4 +- .../src/prompts/tools/chunk-edit.md | 218 +++++++++++++--- .../src/prompts/tools/read-chunk.md | 2 +- .../coding-agent/src/session/agent-storage.ts | 24 +- .../src/session/history-storage.ts | 12 +- .../coding-agent/test/core/chunk-tree.test.ts | 18 +- .../test/tools/chunk-mode.test.ts | 40 ++- packages/natives/CHANGELOG.md | 2 + packages/natives/native/index.d.ts | 2 +- packages/natives/native/index.js | 2 +- 21 files changed, 581 insertions(+), 193 deletions(-) diff --git a/crates/pi-natives/src/chunk/ast_python.rs b/crates/pi-natives/src/chunk/ast_python.rs index 79ab9c52a..348f9cabd 100644 --- a/crates/pi-natives/src/chunk/ast_python.rs +++ b/crates/pi-natives/src/chunk/ast_python.rs @@ -162,12 +162,14 @@ impl LangClassifier for PythonClassifier { "except_clause" => Some(positional_candidate(node, ChunkKind::Except, source)), "match_statement" => Some(positional_candidate(node, ChunkKind::Match, source)), - // ── Variables ── - "expression_statement" | "assignment" => { - Some(group_candidate(node, ChunkKind::Statements, source)) - }, - - _ => None, + // ── Variables / simple statements ── + // Catch `expression_statement`, `assignment`, `return_statement`, + // `raise_statement`, `pass_statement`, `break_statement`, + // `continue_statement`, `delete_statement`, `assert_statement`, + // `nonlocal_statement`, `global_statement`, `type_alias_statement`, + // and any other leaf statements so they merge into the parent + // function body instead of becoming standalone addressable chunks. + _ => Some(group_candidate(node, ChunkKind::Statements, source)), } } } diff --git a/crates/pi-natives/src/chunk/common.rs b/crates/pi-natives/src/chunk/common.rs index b743f7b6a..58cc58034 100644 --- a/crates/pi-natives/src/chunk/common.rs +++ b/crates/pi-natives/src/chunk/common.rs @@ -25,7 +25,7 @@ env_uint! { /// Always recurse into named (non-group) chunks when children exist, /// regardless of the leaf threshold. Enabled via `PI_CHUNK_ALWAYS_RECURSE=1`. pub static ALWAYS_RECURSE: std::sync::LazyLock = std::sync::LazyLock::new(|| { - std::env::var("PI_CHUNK_ALWAYS_RECURSE").map_or(false, |v| !matches!(v.as_str(), "0" | "false")) + std::env::var("PI_CHUNK_ALWAYS_RECURSE").is_ok_and(|v| !matches!(v.as_str(), "0" | "false")) }); /// Suppress `[/chunk#CRC]` closing tags in rendered output. diff --git a/crates/pi-natives/src/chunk/edit.rs b/crates/pi-natives/src/chunk/edit.rs index 12749b413..08ce4724e 100644 --- a/crates/pi-natives/src/chunk/edit.rs +++ b/crates/pi-natives/src/chunk/edit.rs @@ -305,11 +305,15 @@ fn resolve_edit_target( }; let resolved = resolve_chunk_with_crc(state, cleaned_selector.as_deref(), resolve_crc, warnings)?; - let region = operation.region.or(parsed_region); + let mut region = operation.region.or(parsed_region); if !batch_auto_accepted { validate_batch_crc(resolved.chunk, resolved.crc.as_deref(), requires_checksum)?; } let chunk = resolved.chunk.clone(); + if chunk.prologue_end_byte.is_none() || chunk.epilogue_start_byte.is_none() { + region = None; + } + Ok(ResolvedEditTarget { chunk, region }) } @@ -500,8 +504,12 @@ fn apply_insert( file_indent_char, file_indent_step, )?; - let is_prepend_or_append = matches!(operation.op, ChunkEditOp::Prepend | ChunkEditOp::Append); - let spacing = compute_insert_spacing(state, &anchor, pos, is_prepend_or_append); + let suppress_chunk_adjacency = + matches!(operation.op, ChunkEditOp::Prepend | ChunkEditOp::Append) + && !(matches!(operation.op, ChunkEditOp::Append) + && pos == InsertPosition::After + && owned_container_end_line(state, &anchor) > anchor.end_line); + let spacing = compute_insert_spacing(state, &anchor, pos, suppress_chunk_adjacency); let content = operation.content.as_deref().unwrap_or_default(); let mut replacement = normalize_inserted_content( content, @@ -528,14 +536,14 @@ fn apply_insert( && anchor.children.iter().any(|child| child == "preamble") { return Err( - "Comment-only @inner.prepend on root is not allowed when the file has a preamble \ + "Comment-only @body.prepend on root is not allowed when the file has a preamble \ chunk. Use replace on the preamble chunk instead." .to_owned(), ); } if comment_only && !anchor.children.is_empty() { warnings.push( - "Comment-only @inner.prepend can merge into the following chunk's first line; it is \ + "Comment-only @body.prepend can merge into the following chunk's first line; it is \ not a separate named chunk." .to_owned(), ); @@ -602,7 +610,7 @@ fn validate_crc(chunk: &ChunkNode, crc: Option<&str>) -> Result<(), String> { format!( "Checksum required for {}. Re-read the chunk to get the current checksum, then include \ it in the selector. Hint: use target \"{}\" for container replacement, or append \ - another region such as @inner.", + another region such as @body.", chunk_path_opt(chunk), selector ) @@ -693,7 +701,7 @@ fn target_indent_for_region( None | Some(ChunkRegion::Head | ChunkRegion::Tail) => { anchor.indent_char.repeat(anchor.indent as usize) }, - Some(ChunkRegion::Inner) => { + Some(ChunkRegion::Body) => { compute_insert_indent(state, anchor, true, file_indent_char, file_indent_step) }, } @@ -1039,7 +1047,7 @@ fn body_insertion_point( indent: child_indent, }; } - let (_, body_end) = chunk_region_range(anchor, ChunkRegion::Inner); + let (_, body_end) = chunk_region_range(anchor, ChunkRegion::Body); return InsertionPoint { offset: body_end, indent }; } @@ -1055,7 +1063,7 @@ fn body_insertion_point( indent, }; } - let (body_start, _) = chunk_region_range(anchor, ChunkRegion::Inner); + let (body_start, _) = chunk_region_range(anchor, ChunkRegion::Body); InsertionPoint { offset: body_start, indent } } @@ -1078,13 +1086,13 @@ fn resolve_insertion_point( Ok((after_chunk_insertion_point(state, anchor), InsertPosition::After)) }, // Inner first-child position - (Some(ChunkRegion::Inner), ChunkEditOp::Before | ChunkEditOp::Prepend) + (Some(ChunkRegion::Body), ChunkEditOp::Before | ChunkEditOp::Prepend) | (Some(ChunkRegion::Head), ChunkEditOp::After | ChunkEditOp::Append) => Ok(( body_insertion_point(state, anchor, false, file_indent_char, file_indent_step), InsertPosition::FirstChild, )), // Inner last-child position - (Some(ChunkRegion::Inner), ChunkEditOp::After | ChunkEditOp::Append) + (Some(ChunkRegion::Body), ChunkEditOp::After | ChunkEditOp::Append) | (Some(ChunkRegion::Tail), ChunkEditOp::Before | ChunkEditOp::Prepend) => Ok(( body_insertion_point(state, anchor, true, file_indent_char, file_indent_step), InsertPosition::LastChild, @@ -1828,7 +1836,10 @@ mod tests { let result = result.expect("line-number selector should auto-resolve"); assert!( - result.warnings.iter().any(|w| w.contains("Auto-resolved line target")), + result + .warnings + .iter() + .any(|w| w.contains("Auto-resolved line target")), "should warn about auto-resolution: {:?}", result.warnings ); @@ -2143,7 +2154,7 @@ mod tests { let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("fn_main#{}@inner", chunk.checksum)), + sel: Some(format!("fn_main#{}@body", chunk.checksum)), crc: None, region: None, content: Some("\treturn next();\n".to_owned()), @@ -2161,7 +2172,7 @@ mod tests { let result = apply_single_edit(&state, "test.rs", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("fn_main#{}@inner", chunk.checksum)), + sel: Some(format!("fn_main#{}@body", chunk.checksum)), crc: None, region: None, content: Some("\tprintln!(\"new\");\n".to_owned()), @@ -2179,7 +2190,7 @@ mod tests { let result = apply_single_edit(&state, "test.go", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("fn_main#{}@inner", chunk.checksum)), + sel: Some(format!("fn_main#{}@body", chunk.checksum)), crc: None, region: None, content: Some("\treturn\n".to_owned()), @@ -2197,7 +2208,7 @@ mod tests { let result = apply_single_edit(&state, "test.py", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("fn_run#{}@inner", chunk.checksum)), + sel: Some(format!("fn_run#{}@body", chunk.checksum)), crc: None, region: None, content: Some("\treturn 2\n".to_owned()), @@ -2243,7 +2254,7 @@ mod tests { let body_state = state_for(source, "go"); let body_result = apply_single_edit(&body_state, "test.go", EditOperation { op: ChunkEditOp::Append, - sel: Some("type_Server@inner".to_owned()), + sel: Some("type_Server@body".to_owned()), crc: None, region: None, content: Some("\tPort int\n".to_owned()), @@ -2297,6 +2308,30 @@ mod tests { ); } + #[test] + fn go_type_container_append_after_receiver_methods_preserves_sibling_spacing() { + let source = "package main\n\ntype Server struct {}\n\nfunc (s *Server) Start() {}\nfunc (s \ + *Server) Stop() {}\n"; + let state = state_for(source, "go"); + + let result = apply_single_edit(&state, "test.go", EditOperation { + op: ChunkEditOp::Append, + sel: Some("type_Server".to_owned()), + crc: None, + region: None, + content: Some("func (s *Server) Restart() {}".to_owned()), + find: None, + }); + + assert!( + result + .diff_after + .contains("func (s *Server) Stop() {}\n\nfunc (s *Server) Restart() {}"), + "{}", + result.diff_after + ); + } + #[test] fn crc_mismatch_error_includes_fresh_chunk_context() { let source = "class Foo {\n bar() {\n return 1;\n }\n}\n"; @@ -2437,7 +2472,7 @@ mod tests { let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("class_Server.fn_start#{}@inner", chunk.checksum)), + sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)), crc: None, region: None, content: Some("\treturn 42;\n".to_owned()), @@ -2462,7 +2497,7 @@ mod tests { let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("class_Server.fn_start#{}@inner", chunk.checksum)), + sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)), crc: None, region: None, content: Some("\treturn 42;\n".to_owned()), @@ -2488,7 +2523,7 @@ mod tests { let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("class_Server.fn_start#{}@inner", chunk.checksum)), + sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)), crc: None, region: None, content: Some("\t\tif (x) {\n\t\t\ty();\n\t\t}\n".to_owned()), @@ -2504,14 +2539,14 @@ mod tests { #[test] fn body_append_inserts_inside_class() { - // Appending to @inner of a class should insert inside the body, + // Appending to @body of a class should insert inside the body, // not after the closing brace. let source = "class Foo {\n bar() {\n return 1;\n }\n}\n"; let state = state_for(source, "typescript"); let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Append, - sel: Some("class_Foo@inner".to_owned()), + sel: Some("class_Foo@body".to_owned()), crc: None, region: None, content: Some("baz() {\n\treturn 2;\n}\n".to_owned()), @@ -2535,14 +2570,14 @@ mod tests { #[test] fn body_prepend_inserts_after_opening_brace() { - // Prepending to @inner of an enum should insert after the opening brace, + // Prepending to @body of an enum should insert after the opening brace, // not before doc comments. let source = "/** My enum. */\nenum Color {\n Red,\n Green,\n Blue,\n}\n"; let state = state_for(source, "typescript"); let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Prepend, - sel: Some("enum_Color@inner".to_owned()), + sel: Some("enum_Color@body".to_owned()), crc: None, region: None, content: Some("White,\n".to_owned()), @@ -2620,7 +2655,8 @@ mod tests { #[test] fn python_body_append_preserves_indentation() { - let source = "class Server:\n def __init__(self):\n self.x = 1\n\n def start(self):\n pass\n"; + let source = "class Server:\n def __init__(self):\n self.x = 1\n\n def \ + start(self):\n pass\n"; let state = state_for(source, "python"); let result = apply_single_edit(&state, "test.py", EditOperation { @@ -2635,9 +2671,166 @@ mod tests { // The appended method should be at 4-space indent (class member level), // with its body at 8-space indent. assert!( - result.diff_after.contains(" def stop(self):\n pass"), + result + .diff_after + .contains(" def stop(self):\n pass"), "appended method should have correct Python indentation: {}", result.diff_after ); } + + #[test] + fn body_region_on_leaf_without_delimiters_falls_back_to_full_chunk() { + let source = "enum LogLevel {\n Debug,\n Info,\n Warn,\n Fatal,\n}\n"; + let state = state_for(source, "rust"); + let chunk = state + .inner() + .chunk("enum_LogLevel.variant_Info") + .expect("variant_Info should exist"); + assert!(chunk.prologue_end_byte.is_none(), "leaf variant should not have prologue_end_byte"); + + for region_name in ["body", "head", "tail"] { + let sel = format!("enum_LogLevel.variant_Info#{}@{}", chunk.checksum, region_name); + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::Replace, + sel: Some(sel), + crc: None, + region: None, + content: Some("Error,".to_owned()), + find: None, + }], + default_selector: None, + default_crc: None, + anchor_style: None, + cwd: ".".to_owned(), + file_path: "test.rs".to_owned(), + }) + .expect("leaf region should fall back to full chunk"); + + assert!( + result.diff_after.contains("Debug,\n Error,\n Warn,"), + "@{region_name} should replace the full leaf chunk, got: {}", + result.diff_after + ); + } + } + #[test] + fn rust_impl_method_head_replace_no_body_duplication() { + let source = concat!( + "struct Server { +", + " running: bool, +", + "} +", + " +", + "impl Server { +", + " /// Starts the server. +", + " pub fn start(&mut self) { +", + " self.running = true; +", + " println!(\"started\"); +", + " } +", + "} +", + ); + let state = state_for(source, "rust"); + let chunk = state + .inner() + .chunk("impl_Server.fn_start") + .expect("impl_Server.fn_start should exist"); + assert!( + chunk.prologue_end_byte.is_some(), + "fn_start should have prologue_end_byte, got: start_byte={}, end_byte={}, prologue_end_byte={:?}, epilogue_start_byte={:?}", + chunk.start_byte, chunk.end_byte, chunk.prologue_end_byte, chunk.epilogue_start_byte, + ); + + let result = apply_single_edit(&state, "test.rs", EditOperation { + op: ChunkEditOp::Replace, + sel: Some(format!("impl_Server.fn_start#{}@head", chunk.checksum)), + crc: None, + region: None, + content: Some(" /// Initializes and starts the server. + pub fn start(&mut self) {".to_owned()), + find: None, + }); + + let body_count = result.diff_after.matches("self.running = true;").count(); + assert_eq!( + body_count, 1, + "body should appear exactly once after @head replace, got {} occurrences in: +{}", + body_count, result.diff_after + ); + assert!( + result.diff_after.contains("/// Initializes and starts the server."), + "new doc comment should be in output: +{}", result.diff_after + ); + assert!( + !result.diff_after.contains("/// Starts the server."), + "old doc comment should be removed: +{}", result.diff_after + ); + } + + #[test] + fn typescript_class_method_head_replace_no_body_duplication() { + let source = concat!( + "class Server { +", + " /** Starts the server. */ +", + " start() { +", + " this.running = true; +", + " console.log(\"started\"); +", + " } +", + "} +", + ); + let state = state_for(source, "typescript"); + let chunk = state + .inner() + .chunk("class_Server.fn_start") + .expect("class_Server.fn_start should exist"); + assert!( + chunk.prologue_end_byte.is_some(), + "fn_start should have prologue_end_byte" + ); + + let result = apply_single_edit(&state, "test.ts", EditOperation { + op: ChunkEditOp::Replace, + sel: Some(format!("class_Server.fn_start#{}@head", chunk.checksum)), + crc: None, + region: None, + content: Some(" /** Initializes the server. */ + start() {".to_owned()), + find: None, + }); + + let body_count = result.diff_after.matches("this.running = true;").count(); + assert_eq!( + body_count, 1, + "body should appear exactly once after @head replace, got {} occurrences in: +{}", + body_count, result.diff_after + ); + assert!( + result.diff_after.contains("/** Initializes the server. */"), + "new doc comment should be in output: +{}", result.diff_after + ); + } + } diff --git a/crates/pi-natives/src/chunk/indent.rs b/crates/pi-natives/src/chunk/indent.rs index dadc1e8c6..2e2c8621f 100644 --- a/crates/pi-natives/src/chunk/indent.rs +++ b/crates/pi-natives/src/chunk/indent.rs @@ -201,8 +201,7 @@ pub fn reindent_inserted_block( target_indent: &str, file_indent_step: Option, ) -> String { - let lines = content.split('\n').collect::>(); - if lines.is_empty() { + if content.is_empty() { return String::new(); } diff --git a/crates/pi-natives/src/chunk/mod.rs b/crates/pi-natives/src/chunk/mod.rs index d9602b23e..e03114b6d 100644 --- a/crates/pi-natives/src/chunk/mod.rs +++ b/crates/pi-natives/src/chunk/mod.rs @@ -58,6 +58,7 @@ mod ast_vue; use std::collections::HashMap; + use ast_grep_core::tree_sitter::LanguageExt; use napi::{Error, Result}; use napi_derive::napi; @@ -1727,7 +1728,7 @@ impl Config { .expect("state should parse"); let result = state .render_read(ReadRenderParams { - read_path: "sample.ts:fn_run@inner".to_string(), + read_path: "sample.ts:fn_run@body".to_string(), display_path: "sample.ts".to_string(), language_tag: Some("ts".to_string()), omit_checksum: false, @@ -2204,4 +2205,65 @@ end tool.end_line, ); } + + #[test] + fn python_property_no_orphan_return_chunk() { + // Build a function large enough to trigger recursion (> LEAF_THRESHOLD) + // with multiple control-flow children that narrow scope enough to trigger + // recursion, plus a `return` statement that must NOT become a standalone + // chunk. + let mut body = String::new(); + body.push_str("class Server:\n"); + body.push_str(" @property\n"); + body.push_str(" def address(self) -> str:\n"); + body.push_str(" if self._host:\n"); + for i in 0..20 { + let _ = writeln!(body, " x{i} = {i}"); + } + body.push_str(" if self._port:\n"); + for i in 0..20 { + let _ = writeln!(body, " y{i} = {i}"); + } + body.push_str(" return f\"{{self._host}}:{{self._port}}\"\n"); + let source = body; + let tree = build_chunk_tree(source.as_str(), "python").expect("tree should build"); + + // Dump all chunk paths for debugging + for chunk in &tree.chunks { + eprintln!( + "chunk: path={:?} kind={:?} leaf={} lines={}-{}", + chunk.path, chunk.kind, chunk.leaf, chunk.start_line, chunk.end_line + ); + } + + // The function body should recurse (it's large enough) but the return + // statement should NOT become a standalone addressable chunk. + let fn_chunk = tree + .chunks + .iter() + .find(|c| c.path == "class_Server.fn_address") + .expect("fn_address should exist"); + + let orphan_ret = tree.chunks.iter().find(|c| c.path.contains("ret")); + assert!( + orphan_ret.is_none(), + "return statement inside property method should not be a separate chunk, found: {:?}", + orphan_ret.map(|c| (&c.path, &c.kind)) + ); + + // Verify the function actually recursed (has children) + assert!( + !fn_chunk.leaf, + "fn_address should recurse into children for this test to be meaningful" + ); + } + + #[test] + + + #[test] + + + #[test] + } diff --git a/crates/pi-natives/src/chunk/resolve.rs b/crates/pi-natives/src/chunk/resolve.rs index f2c35c39a..bbac388de 100644 --- a/crates/pi-natives/src/chunk/resolve.rs +++ b/crates/pi-natives/src/chunk/resolve.rs @@ -14,18 +14,15 @@ pub struct ResolvedChunk<'a> { fn parse_region_name(value: &str) -> Option { match value.trim() { - "head" | "prologue" => Some(ChunkRegion::Head), - "inner" | "body" => Some(ChunkRegion::Inner), - "tail" | "epilogue" => Some(ChunkRegion::Tail), + "head" => Some(ChunkRegion::Head), + "body" => Some(ChunkRegion::Body), + "tail" => Some(ChunkRegion::Tail), _ => None, } } fn is_known_region_name(value: &str) -> bool { - matches!( - value.trim(), - "outer" | "container" | "head" | "prologue" | "inner" | "body" | "tail" | "epilogue" - ) + matches!(value.trim(), "head" | "body" | "tail") } /// Split a trailing `@region` suffix from a selector. Returns the selector @@ -69,7 +66,7 @@ pub fn split_selector_crc_and_region( (prefix, parsed_region) } else if let Some((_, suffix)) = raw.rsplit_once('@') { return Err(format!( - "Unknown chunk region \"{}\". Valid regions: head, inner, tail (or omit for the full \ + "Unknown chunk region \"{}\". Valid regions: head, body, tail (or omit for the full \ chunk).", suffix.trim() )); @@ -201,7 +198,7 @@ pub fn chunk_region_range(chunk: &ChunkNode, region: ChunkRegion) -> (usize, usi let epi_start = chunk.epilogue_start_byte.map_or(end, |b| b as usize); match region { ChunkRegion::Head => (start, pro_end), - ChunkRegion::Inner => (pro_end, epi_start), + ChunkRegion::Body => (pro_end, epi_start), ChunkRegion::Tail => (epi_start, end), } } @@ -226,20 +223,12 @@ fn resolve_chunk_selector_impl<'a>( }; if is_line_number_selector(cleaned) { - if let Some(line) = parse_line_number(cleaned) { - if let Some(chunk_path) = - crate::chunk::line_to_chunk_path(state.tree(), line) - { - warnings.push(format!( - "Auto-resolved line target \"{cleaned}\" to chunk \"{chunk_path}\"." - )); - return resolve_chunk_selector_impl( - state, - Some(&chunk_path), - crc, - warnings, - ); - } + if let Some(line) = parse_line_number(cleaned) + && let Some(chunk_path) = crate::chunk::line_to_chunk_path(state.tree(), line) + { + warnings + .push(format!("Auto-resolved line target \"{cleaned}\" to chunk \"{chunk_path}\".")); + return resolve_chunk_selector_impl(state, Some(&chunk_path), crc, warnings); } return Err(format!( "Line target \"{cleaned}\" does not fall inside any chunk. Use chunk paths like \ diff --git a/crates/pi-natives/src/chunk/types.rs b/crates/pi-natives/src/chunk/types.rs index 832a169a0..48420d6ef 100644 --- a/crates/pi-natives/src/chunk/types.rs +++ b/crates/pi-natives/src/chunk/types.rs @@ -90,8 +90,8 @@ pub enum ChunkReadStatus { pub enum ChunkRegion { #[napi(value = "head")] Head, - #[napi(value = "inner")] - Inner, + #[napi(value = "body")] + Body, #[napi(value = "tail")] Tail, } @@ -100,7 +100,7 @@ impl ChunkRegion { pub const fn as_str(self) -> &'static str { match self { Self::Head => "head", - Self::Inner => "inner", + Self::Body => "body", Self::Tail => "tail", } } diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 9fdb4473b..8f2a04739 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -2205,7 +2205,7 @@ export class AuthCredentialStore { } #initializeSchema(): void { - this.#db.exec(` + this.#db.run(` PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; @@ -2276,7 +2276,7 @@ export class AuthCredentialStore { } #createAuthCredentialsTable(): void { - this.#db.exec(` + this.#db.run(` CREATE TABLE IF NOT EXISTS auth_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL, @@ -2292,7 +2292,7 @@ export class AuthCredentialStore { } #createAuthCredentialIndexes(): void { - this.#db.exec(` + this.#db.run(` CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider); CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL; `); @@ -2315,8 +2315,8 @@ export class AuthCredentialStore { const v0Cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>; const hasDisabled = v0Cols.some(col => col.name === "disabled"); - this.#db.exec("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0"); - this.#db.exec(` + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0"); + this.#db.run(` CREATE TABLE auth_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL, @@ -2327,7 +2327,7 @@ export class AuthCredentialStore { updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) ); `); - this.#db.exec(` + this.#db.run(` INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at) SELECT id, @@ -2339,16 +2339,16 @@ export class AuthCredentialStore { updated_at FROM auth_credentials_v0 `); - this.#db.exec("DROP TABLE auth_credentials_v0"); + this.#db.run("DROP TABLE auth_credentials_v0"); }); migrate(); } #migrateAuthSchemaV1OrV2ToV3(): void { const migrate = this.#db.transaction(() => { - this.#db.exec("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy"); + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy"); this.#createAuthCredentialsTable(); - this.#db.exec(` + this.#db.run(` INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) SELECT id, @@ -2361,16 +2361,16 @@ export class AuthCredentialStore { updated_at FROM auth_credentials_legacy `); - this.#db.exec("DROP TABLE auth_credentials_legacy"); + this.#db.run("DROP TABLE auth_credentials_legacy"); }); migrate(); } #migrateAuthSchemaV3ToV4(): void { const migrate = this.#db.transaction(() => { - this.#db.exec("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3"); + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3"); this.#createAuthCredentialsTable(); - this.#db.exec(` + this.#db.run(` INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) SELECT id, @@ -2383,7 +2383,7 @@ export class AuthCredentialStore { updated_at FROM auth_credentials_v3 `); - this.#db.exec("DROP TABLE auth_credentials_v3"); + this.#db.run("DROP TABLE auth_credentials_v3"); }); migrate(); } diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index 7ef20036d..10a8cd11c 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -556,12 +556,12 @@ describe("Tool argument coercion", () => { id: "call-heal-esc-1", name: "heal-esc-1", arguments: { - edits: '[{"target": "fn_foo#ABCD@inner", "content": "return 1;\\n"}\\n]', + edits: '[{"target": "fn_foo#ABCD@body", "content": "return 1;\\n"}\\n]', }, }; const result = validateToolArguments(tool, toolCall); - expect(result.edits).toEqual([{ target: "fn_foo#ABCD@inner", content: "return 1;\n" }]); + expect(result.edits).toEqual([{ target: "fn_foo#ABCD@body", content: "return 1;\n" }]); }); it("heals stringified array with trailing junk after balanced container", () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index efb830985..92a0d38fa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,8 +9,8 @@ ### Added -- Auto QA tool (`report_tool_issue`) for automated tracking of unexpected tool behavior; enabled via `PI_AUTO_QA=1` environment variable or `pi.autoqa` setting -- `pi.autoqa` setting to enable automated tool issue reporting for all agents +- Auto QA tool (`report_tool_issue`) for automated tracking of unexpected tool behavior; enabled via `PI_AUTO_QA=1` environment variable or `dev.autoqa` setting +- `dev.autoqa` setting to enable automated tool issue reporting for all agents - System prompt guidance when `report_tool_issue` tool is available, encouraging agents to report tool behavior discrepancies - LSP server discovery at startup via `discoverStartupLspServers()` to detect configured language servers without blocking initialization - LSP startup event channel (`lsp:startup`) for asynchronous server warmup notifications with completion or failure status @@ -72,6 +72,18 @@ ### Changed +- Chunk edit tool schema: renamed `target` parameter to `sel` for consistency with read tool terminology +- Chunk edit tool: `op` parameter is now required (previously optional with `replace` default) +- Chunk edit documentation: updated all region references from `@inner` to `@body` for clearer semantics +- Chunk edit documentation: expanded with comprehensive real-world examples showing full read output, operation effects, and indentation rules +- Chunk edit documentation: simplified indentation guidance to write content at indent-level 0 with automatic re-indentation by the tool +- Chunk edit documentation: clarified that `@region` only works on container chunks, not leaf chunks +- Chunk edit documentation: emphasized that CRCs change after every edit and must be refreshed from latest responses +- Read chunk tool documentation: updated selector examples to use `@body` instead of `@inner` +- Chunk edit region terminology updated: `@inner` renamed to `@body` for clearer semantics in container chunks +- Chunk edit documentation restructured with comprehensive examples showing full read output, operation effects, and indentation rules +- Chunk edit indentation guidance simplified: content should be written at indent-level 0 and the tool automatically applies correct base indentation +- Chunk edit examples expanded with realistic TypeScript code samples demonstrating replace, insert, prepend, append, and delete operations - Python tool description now dynamically reflects prelude documentation availability instead of static text - Python tool now automatically warms the environment on first execution if prelude helpers are unavailable, ensuring documentation is loaded before use - Tool creation now auto-injects `report_tool_issue` when auto QA is enabled, regardless of requested tool list @@ -102,6 +114,8 @@ ### Fixed +- Chunk edit parameter validation: corrected detection of chunk edit operations to check for `sel` field instead of `target` +- Chunk edit streaming previews: updated to reference `sel` parameter instead of `target` - Python prelude introspection now respects execution timeout and signal options, preventing hangs during environment warmup - Welcome banner LSP server status now updates in real-time when background startup warmup completes, eliminating stale connecting status displays - Welcome banner LSP startup rows now re-render when background warmup finishes, use the pending status symbol while servers are still connecting, and no longer add a redundant `LSP ready` status line on successful startup diff --git a/packages/coding-agent/src/edit/modes/chunk.ts b/packages/coding-agent/src/edit/modes/chunk.ts index 5b0a6ae70..c6e679761 100644 --- a/packages/coding-agent/src/edit/modes/chunk.ts +++ b/packages/coding-agent/src/edit/modes/chunk.ts @@ -120,10 +120,6 @@ function fileLanguageTag(filePath: string, language?: string): string | undefine return ext.length > 0 ? ext : undefined; } -function resolveChunkTarget(target: string): ParsedChunkTarget { - return { selector: target }; -} - async function resolveChunkSourceContext(session: ToolSession, path: string): Promise { const resolvedPath = resolvePlanPath(session, path); const sourceFile = Bun.file(resolvedPath); @@ -314,11 +310,11 @@ export function missingChunkReadTarget(selector: string): ChunkReadTarget { const CHUNK_OP_VALUES = ["replace", "after", "before", "prepend", "append"] as const; export const chunkToolEditSchema = Type.Object({ - target: Type.String({ + op: StringEnum(CHUNK_OP_VALUES), + sel: Type.String({ description: - "Chunk selector. Format: 'path@region' for insertions, 'path#CRC@region' for replace. Omit @region to target the full chunk. Valid regions: head, inner, tail.", + "Chunk selector. Format: 'path@region' for insertions, 'path#CRC@region' for replace. Omit @region to target the full chunk. Valid regions: head, body, tail.", }), - op: Type.Optional(StringEnum(CHUNK_OP_VALUES)), content: Type.String({ description: "New content. Use \\t for indentation. Do NOT include the chunk's base padding.", }), @@ -355,31 +351,12 @@ export function isChunkParams(params: unknown): params is ChunkParams { params.edits.length > 0 && typeof params.edits[0] === "object" && params.edits[0] !== null && - "target" in params.edits[0] + "sel" in params.edits[0] ); } -function parseChunkTarget(target: string): ParsedChunkTarget { - return resolveChunkTarget(target); -} - -function normalizeChunkEditOperation(edit: ChunkToolEdit): ChunkEditOperation { - const { selector } = parseChunkTarget(edit.target); - const op = edit.op ?? "replace"; - const content = edit.content; - return { - op, - sel: selector, - content, - }; -} - function normalizeChunkEditOperations(edits: ChunkToolEdit[]): ChunkEditOperation[] { - const operations: ChunkEditOperation[] = []; - for (const edit of edits) { - operations.push(normalizeChunkEditOperation(edit)); - } - return operations; + return edits as ChunkEditOperation[]; } async function writeChunkResult(params: { diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 29c19ba9f..46aab0eca 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -182,7 +182,7 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe } function isChunkStreamingEdit(edit: Partial): edit is Partial { - return "target" in edit; + return "sel" in edit; } function getStreamingEditContent(content: unknown): string { @@ -226,7 +226,7 @@ function formatChunkStreamingEdit(edit: Partial): FormattedStream } const contentLines = getStreamingEditContent(edit.content); - const target = edit.target ?? "?"; + const target = edit.sel ?? "?"; const op = edit.op ?? "replace"; switch (op) { diff --git a/packages/coding-agent/src/prompts/tools/chunk-edit.md b/packages/coding-agent/src/prompts/tools/chunk-edit.md index 0322672cf..1b502ae56 100644 --- a/packages/coding-agent/src/prompts/tools/chunk-edit.md +++ b/packages/coding-agent/src/prompts/tools/chunk-edit.md @@ -1,66 +1,206 @@ -Edits files via syntax-aware chunks. Run `read(path="file.ts")` first. The edit target is a chunk selector, optionally qualified with a region. +Edits files via syntax-aware chunks. Run `read(path="file.ts")` first. The edit selector is a chunk path, optionally qualified with a region. - **MUST** `read` first. Never invent chunk paths or CRCs. Copy them from the latest `read` output (opening lines start with `+++`) or edit response. -- `target` format: +- `sel` format: - insertions: `chunk` or `chunk@region` - replacements: `chunk#CRC` or `chunk#CRC@region` -- Without a `@region` it defaults to the entire chunk. Valid regions: `head`, `inner`, `tail`. +- Without a `@region` it defaults to the entire chunk. Valid regions: `head`, `body`, `tail`. - If the exact chunk path is unclear, run `read(path="file", sel="?")` and copy a selector from that listing. -- Use `\t` for indentation in `content`. Do **NOT** include the chunk's base indentation. Only indent relative to the chunk's opening level. +- Use `\t` for indentation in `content`. Write content at indent-level 0 — the tool re-indents it to match the chunk's position in the file. For example, to replace `@body` of a method, write the body starting at column 0: + ``` + content: "if (x) {\n\treturn true;\n}" + ``` + The tool adds the correct base indent automatically. Never manually pad with the chunk's own indentation. +- `@region` only works on container chunks (classes, functions, impl blocks, sections). Do **not** use `@head`/`@body`/`@tail` on leaf chunks (enum variants, fields, single statements) — use the whole chunk instead. - `replace` requires the current CRC. Insertions do not. -- Successful edits return refreshed chunk anchors. Use the latest selectors/CRCs for follow-up edits. +- **CRCs change after every edit.** Always use the selectors/CRCs from the most recent `read` or edit response. Never reuse a CRC from a previous edit. - `@head` — attached trivia, header/signature, and opening delimiter. -- `@inner` — the editable interior only. +- `@body` — the editable interior only. - `@tail` — the closing delimiter or trailing owned trailer. -For leaf chunks (fields, variants, single-line items), `@inner` falls back to the full chunk. +For leaf chunks (fields, variants, single-line items), `@body` falls back to the full chunk. -**Important:** `append`/`prepend` without a `@region` inserts *outside* the chunk. To add children *inside* a class, struct, enum, or function body, use `@inner`: -- `class_Foo@inner` + `append` → adds inside the class before `}` -- `class_Foo@inner` + `prepend` → adds inside the class after `{` +**Important:** `append`/`prepend` without a `@region` inserts _outside_ the chunk. To add children _inside_ a class, struct, enum, or function body, use `@body`: +- `class_Foo@body` + `append` → adds inside the class before `}` +- `class_Foo@body` + `prepend` → adds inside the class after `{` - `class_Foo` + `append` → adds after the entire class (after `}`) - + -|op|target form|effect| +|op|sel|effect| |---|---|---| -|`replace`|`chunk#CRC` or `chunk#CRC@region`|rewrite the addressed region| -|`before`|`chunk` or `chunk@region`|insert before the region span| -|`after`|`chunk` or `chunk@region`|insert after the region span| -|`prepend`|`chunk` or `chunk@region`|insert at the start inside the region| -|`append`|`chunk` or `chunk@region`|insert at the end inside the region| +|`replace`|`chunk#CRC(@region)?`|rewrite the addressed region| +|`before`|`chunk(@region)?`|insert before the region span| +|`after`|`chunk(@region)?`|insert after the region span| +|`prepend`|`chunk(@region)?`|insert at the start inside the region| +|`append`|`chunk(@region)?`|insert at the end inside the region| -- Replace only a function body without touching the closing brace: - - `target: "fn_main#ABCD@inner"` - - `op: "replace"` - - `content: "\treturn compute();\n"` -- Insert a new top-level function after another top-level function: - - `target: "fn_prev"` - - `op: "after"` - - `content: "function next(): void {\n\twork();\n}\n"` -- Add a struct field: - - `target: "type_Server@inner"` - - `op: "append"` - - `content: "\tport int\n"` -- Add a Go receiver method owned by the type, not a struct field: - - `target: "type_Server"` - - `op: "append"` - - `content: "func (s *Server) Stop() error {\n\treturn nil\n}\n"` -- Edit a doc comment or header block: - - `target: "fn_foo#WXYZ@head"` - - `op: "replace"` - - `content: "/**\n * Updated docs.\n */\nfunction foo() {"` +Given this `read` output for `example.ts`: +~~~ + | example.ts·34L·ts·#QBMH + | + | +++interface_Config#BWTR + 1| interface Config { + | +++interface_Config.field_host#TTMN + 2| host: string; + | +++interface_Config.field_port#QSMH + 3| port: number; + | +++interface_Config.field_debug#JPRR + 4| debug: boolean; + 5| } + | + | +++class_Counter#HZHY + 7| class Counter { + | +++class_Counter.field_value#QJBY + 8| value: number = 0; + 9| + | +++class_Counter.fn_increment#NQWY +10| increment(): void { +11| this.value += 1; +12| } +13| + | +++class_Counter.fn_decrement#PMBP +14| decrement(): void { +15| this.value -= 1; +16| } +17| + | +++class_Counter.fn_toString#ZQZP +18| toString(): string { +19| return `Counter(${this.value})`; +20| } +21| } + | + | +++enum_Status#HYQJ +23| enum Status { + | +++enum_Status.variant_Active#PQNS +24| Active = "ACTIVE", + | +++enum_Status.variant_Paused#HHNM +25| Paused = "PAUSED", + | +++enum_Status.variant_Stopped#NHTY +26| Stopped = "STOPPED", +27| } + | + | +++fn_createCounter#PQQY +29| function createCounter(initial: number): Counter { +30| const counter = new Counter(); +31| counter.value = initial; +32| return counter; +33| } +~~~ + +**Replace a whole chunk** (rename a function): +~~~json +{ "sel": "fn_createCounter#PQQY", "op": "replace", "content": "function makeCounter(start: number): Counter {\n\tconst c = new Counter();\n\tc.value = start;\n\treturn c;\n}\n" } +~~~ +Result — the entire chunk is rewritten: +~~~ +function makeCounter(start: number): Counter { + const c = new Counter(); + c.value = start; + return c; +} +~~~ + +**Replace a method body** (`@body`): +~~~json +{ "sel": "class_Counter.fn_increment#NQWY@body", "op": "replace", "content": "this.value += 1;\nconsole.log('incremented to', this.value);\n" } +~~~ +Result — only the body changes, signature and braces are kept: +~~~ + increment(): void { + this.value += 1; + console.log('incremented to', this.value); + } +~~~ + +**Replace a function header** (`@head` — signature and doc comment): +~~~json +{ "sel": "fn_createCounter#PQQY@head", "op": "replace", "content": "/** Creates a counter with the given start value. */\nfunction createCounter(initial: number, label?: string): Counter {\n" } +~~~ +Result — adds a doc comment and updates the signature, body untouched: +~~~ +/** Creates a counter with the given start value. */ +function createCounter(initial: number, label?: string): Counter { + const counter = new Counter(); + counter.value = initial; + return counter; +} +~~~ + +**Insert before a chunk** (`before`): +~~~json +{ "sel": "fn_createCounter", "op": "before", "content": "/** Factory function below. */\n" } +~~~ +Result — a comment is inserted before the function: +~~~ +/** Factory function below. */ + +function createCounter(initial: number): Counter { +~~~ + +**Insert after a chunk** (`after`): +~~~json +{ "sel": "enum_Status", "op": "after", "content": "\nfunction isActive(s: Status): boolean {\n\treturn s === Status.Active;\n}\n" } +~~~ +Result — a new function appears after the enum: +~~~ +enum Status { + Active = "ACTIVE", + Paused = "PAUSED", + Stopped = "STOPPED", +} + +function isActive(s: Status): boolean { + return s === Status.Active; +} + +function createCounter(initial: number): Counter { +~~~ + +**Prepend inside a container** (`@body` + `prepend`): +~~~json +{ "sel": "class_Counter@body", "op": "prepend", "content": "label: string = 'default';\n\n" } +~~~ +Result — a new field is added at the top of the class body, before existing members: +~~~ +class Counter { + label: string = 'default'; + + value: number = 0; +~~~ + +**Append inside a container** (`@body` + `append`): +~~~json +{ "sel": "class_Counter@body", "op": "append", "content": "\nreset(): void {\n\tthis.value = 0;\n}\n" } +~~~ +Result — a new method is added at the end of the class body, before the closing `}`: +~~~ + toString(): string { + return `Counter(${this.value})`; + } + + reset(): void { + this.value = 0; + } +} +~~~ + +**Delete a chunk** (`replace` with empty content): +~~~json +{ "sel": "class_Counter.fn_toString#ZQZP", "op": "replace", "content": "" } +~~~ +Result — the method is removed from the class. - Indentation rules (important): - Use `\t` for each indent level. The tool converts tabs to the file's actual style (2-space, 4-space, etc.). - Do NOT include the chunk's base indentation — only indent relative to the region's opening level. - - For `@inner` of a function: `\t` = one level inside the body. Write `"\treturn x;\n"`, not `"\t\t\treturn x;\n"`. - - For `@head`: `\t` = one level at the chunk's own depth. A class member's head uses `"\t/** doc */\n\tstart(): void {"`. + - For `@body` of a function: write at column 0, e.g. `"return x;\n"`. The tool adds the correct base indent. + - For `@head`: write at the chunk's own depth. A class member's head uses `"/** doc */\nstart(): void {"`. - For a top-level item: start at zero indent. Write `"function foo() {\n\treturn 1;\n}\n"`. - The tool strips common leading indentation from your content as a safety net, so accidental over-indentation is corrected. diff --git a/packages/coding-agent/src/prompts/tools/read-chunk.md b/packages/coding-agent/src/prompts/tools/read-chunk.md index b7acc379a..5638fa5ad 100644 --- a/packages/coding-agent/src/prompts/tools/read-chunk.md +++ b/packages/coding-agent/src/prompts/tools/read-chunk.md @@ -2,7 +2,7 @@ Reads files using syntax-aware chunks. - `path` — file path or URL; may include `:selector` suffix -- `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar#ABCD@inner`, `?`, `L50`, `L50-L120`, or `raw` +- `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar#ABCD@body`, `?`, `L50`, `L50-L120`, or `raw` - `timeout` — seconds, for URLs only Each opening anchor `+++ full.chunk.path#CCCC` in the default output identifies a chunk (with matching closers like `--- /full.chunk.path#CCCC`). Use `full.chunk.path#CCCC` as-is to read truncated chunks. diff --git a/packages/coding-agent/src/session/agent-storage.ts b/packages/coding-agent/src/session/agent-storage.ts index c6b37c8c6..7af2dac44 100644 --- a/packages/coding-agent/src/session/agent-storage.ts +++ b/packages/coding-agent/src/session/agent-storage.ts @@ -73,7 +73,7 @@ export class AgentStorage { * AuthCredentialStore handles auth_credentials and cache tables. */ #initializeSchema(): void { - this.#db.exec(` + this.#db.run(` PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; @@ -92,7 +92,7 @@ CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY); const hasValue = settingsInfo.some(column => column.name === "value"); if (!hasSettingsTable) { - this.#db.exec(` + this.#db.run(` CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -117,8 +117,8 @@ CREATE TABLE settings ( } const migrate = this.#db.transaction((settings: Record | null) => { - this.#db.exec("DROP TABLE settings"); - this.#db.exec(` + this.#db.run("DROP TABLE settings"); + this.#db.run(` CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -169,34 +169,34 @@ CREATE TABLE settings ( #migrateSchemaV4ToV5(): void { const migrate = this.#db.transaction(() => { - this.#db.exec("ALTER TABLE settings RENAME TO settings_legacy"); - this.#db.exec(` + this.#db.run("ALTER TABLE settings RENAME TO settings_legacy"); + this.#db.run(` CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) ); `); - this.#db.exec(` + this.#db.run(` INSERT INTO settings (key, value, updated_at) SELECT key, value, updated_at FROM settings_legacy `); - this.#db.exec("DROP TABLE settings_legacy"); + this.#db.run("DROP TABLE settings_legacy"); - this.#db.exec("ALTER TABLE model_usage RENAME TO model_usage_legacy"); - this.#db.exec(` + this.#db.run("ALTER TABLE model_usage RENAME TO model_usage_legacy"); + this.#db.run(` CREATE TABLE model_usage ( model_key TEXT PRIMARY KEY, last_used_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) ); `); - this.#db.exec(` + this.#db.run(` INSERT INTO model_usage (model_key, last_used_at) SELECT model_key, last_used_at FROM model_usage_legacy `); - this.#db.exec("DROP TABLE model_usage_legacy"); + this.#db.run("DROP TABLE model_usage_legacy"); }); migrate(); } diff --git a/packages/coding-agent/src/session/history-storage.ts b/packages/coding-agent/src/session/history-storage.ts index 8907890ce..161edadca 100644 --- a/packages/coding-agent/src/session/history-storage.ts +++ b/packages/coding-agent/src/session/history-storage.ts @@ -39,7 +39,7 @@ export class HistoryStorage { const hasFts = this.#db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_fts'").get(); - this.#db.exec(` + this.#db.run(` PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; @@ -150,11 +150,11 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN #migrateHistorySchema(): void { const migrate = this.#db.transaction(() => { - this.#db.exec("ALTER TABLE history RENAME TO history_legacy"); - this.#db.exec("DROP INDEX IF EXISTS idx_history_created_at"); - this.#db.exec("DROP TRIGGER IF EXISTS history_ai"); - this.#db.exec("DROP TABLE IF EXISTS history_fts"); - this.#db.exec(` + this.#db.run("ALTER TABLE history RENAME TO history_legacy"); + this.#db.run("DROP INDEX IF EXISTS idx_history_created_at"); + this.#db.run("DROP TRIGGER IF EXISTS history_ai"); + this.#db.run("DROP TABLE IF EXISTS history_fts"); + this.#db.run(` CREATE TABLE history ( id INTEGER PRIMARY KEY AUTOINCREMENT, prompt TEXT NOT NULL, diff --git a/packages/coding-agent/test/core/chunk-tree.test.ts b/packages/coding-agent/test/core/chunk-tree.test.ts index 4d95a432f..43530df9c 100644 --- a/packages/coding-agent/test/core/chunk-tree.test.ts +++ b/packages/coding-agent/test/core/chunk-tree.test.ts @@ -97,11 +97,7 @@ function getChecksum(source: string, chunkPath: string, language = "typescript") return chunk.checksum; } -function targetWithChecksum( - chunkPath: string, - checksum: string, - region?: "head" | "inner" | "tail", -): string { +function targetWithChecksum(chunkPath: string, checksum: string, region?: "head" | "body" | "tail"): string { return `${chunkPath}#${checksum}${region ? `@${region}` : ""}`; } @@ -109,13 +105,13 @@ function currentTarget( source: string, chunkPath: string, language = "typescript", - region?: "head" | "inner" | "tail", + region?: "head" | "body" | "tail", ): string { return targetWithChecksum(chunkPath, getChecksum(source, chunkPath, language), region); } function bodyTarget(chunkPath: string): string { - return `${chunkPath}@inner`; + return `${chunkPath}@body`; } describe("applyChunkEdits", () => { @@ -1084,9 +1080,9 @@ describe("prepend warnings", () => { language: "go", cwd: "/", filePath: "main.go", - operations: [{ op: "prepend", sel: "@inner", content: "// AUTO-GENERATED\n" }], + operations: [{ op: "prepend", sel: "@body", content: "// AUTO-GENERATED\n" }], }); - expect(result.warnings.some(w => w.includes("Comment-only @inner.prepend"))).toBe(true); + expect(result.warnings.some(w => w.includes("Comment-only @body.prepend"))).toBe(true); }); }); @@ -1147,9 +1143,9 @@ describe("prepend preamble guard", () => { language: "javascript", cwd: "/", filePath: "index.js", - operations: [{ op: "prepend", sel: "@inner", content: "// AUTO-GENERATED\n" }], + operations: [{ op: "prepend", sel: "@body", content: "// AUTO-GENERATED\n" }], }), - ).toThrow(/Comment-only @inner.prepend on root is not allowed when the file has a preamble/); + ).toThrow(/Comment-only @body.prepend on root is not allowed when the file has a preamble/); }); }); diff --git a/packages/coding-agent/test/tools/chunk-mode.test.ts b/packages/coding-agent/test/tools/chunk-mode.test.ts index 30c8b9081..de9b57a5d 100644 --- a/packages/coding-agent/test/tools/chunk-mode.test.ts +++ b/packages/coding-agent/test/tools/chunk-mode.test.ts @@ -218,7 +218,8 @@ describe("chunk mode tools", () => { path: filePath, edits: [ { - target: `class_Server.fn_handleError#${checksum}`, + sel: `class_Server.fn_handleError#${checksum}`, + op: "replace", content: ` private handleError(err: Error): string { return \`normalized:\${err.message}\`; } @@ -262,11 +263,13 @@ describe("chunk mode tools", () => { path: filePath, edits: [ { - target: `${chunkPath}#${checksum}`, + sel: `${chunkPath}#${checksum}`, + op: "replace", content: buildHandleErrorMethod({ returnLine: " return err.message.toUpperCase() + total;" }), }, { - target: `${chunkPath}#${checksum2}`, + sel: `${chunkPath}#${checksum2}`, + op: "replace", content: buildHandleErrorMethod({ totalInitLine: " let total = 1;", returnLine: " return err.message.toUpperCase() + total;", @@ -310,7 +313,7 @@ describe("chunk mode tools", () => { path: filePath, edits: [ { - target: "class_Server@inner", + sel: "class_Server@body", op: "append", content: 'status(): string {\n return "ok";\n}\n', }, @@ -332,7 +335,7 @@ describe("chunk mode tools", () => { await editTool.execute("chunk-edit-empty-replace-delete", { path: filePath, - edits: [{ target: `fn_main#${checksum}`, content: "" }], + edits: [{ sel: `fn_main#${checksum}`, op: "replace", content: "" }], }); const updatedSource = await Bun.file(filePath).text(); @@ -366,8 +369,8 @@ describe("chunk mode tools", () => { editTool.execute("chunk-edit-batch-rollback", { path: filePath, edits: [ - { target: "class_Server", op: "append", content: ' status(): string {\n return "ok";\n }' }, - { target: "class_Server.fn_handleError#ZZZZ", content: "" }, + { sel: "class_Server", op: "append", content: ' status(): string {\n return "ok";\n }' }, + { sel: "class_Server.fn_handleError#ZZZZ", op: "replace", content: "" }, ], }), ).rejects.toThrow(/No changes were saved/); @@ -388,7 +391,8 @@ describe("chunk mode tools", () => { path: filePath, edits: [ { - target: `class_Server.fn_handleError#${checksum}`, + sel: `class_Server.fn_handleError#${checksum}`, + op: "replace", content: " private handleError(err: Error): string {\n if (err) {\n", }, ], @@ -410,11 +414,13 @@ describe("chunk mode tools", () => { path: filePath, edits: [ { - target: `class_Server.fn_handleError#${checksum}`, + sel: `class_Server.fn_handleError#${checksum}`, + op: "replace", content: " private handleError(err: Error): string {\n return err.message;\n }", }, { - target: `class_Server.fn_handleError#${checksum}`, + sel: `class_Server.fn_handleError#${checksum}`, + op: "replace", content: " private handleError(err: Error): string {\n return err.message.toUpperCase();\n }", }, ], @@ -436,7 +442,8 @@ describe("chunk mode tools", () => { path: filePath, edits: [ { - target: "class_Server.fn_handleError", + sel: "class_Server.fn_handleError", + op: "replace", content: buildHandleErrorMethod({ totalInitLine: " let total = 1;" }), }, ], @@ -456,7 +463,13 @@ describe("chunk mode tools", () => { // Use bare "main" instead of "fn_main" const _result = await editTool.execute("chunk-edit-prefix-resolve", { path: filePath, - edits: [{ target: `main#${checksum}`, content: 'function main(): void {\n console.log("started");\n}\n' }], + edits: [ + { + sel: `main#${checksum}`, + op: "replace", + content: 'function main(): void {\n console.log("started");\n}\n', + }, + ], }); const updatedSource = await Bun.file(filePath).text(); expect(updatedSource).toContain('console.log("started")'); @@ -502,7 +515,8 @@ describe("chunk mode tools", () => { path: filePath, edits: [ { - target: `${building.path}#${building.checksum}`, + sel: `${building.path}#${building.checksum}`, + op: "replace", content: "## Building and Testing\n\nUse `just verify` instead. It wraps cmake and ctest.\n", }, ], diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index ef61e3cb7..902835b14 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog ## [Unreleased] + ### Breaking Changes +- Changed `ChunkRegion.Inner` enum value to `ChunkRegion.Body` to align with region semantics - Changed `ChunkRegion` enum values from `Container`, `Prologue`, `Body`, `Epilogue` to `Head`, `Inner`, `Tail` with updated semantics for region targeting - Replaced `ChunkEditOp` enum values — `AppendChild`, `PrependChild`, `AppendSibling`, `PrependSibling`, and `ReplaceBody` are now `Before`, `After`, `Prepend`, and `Append` with updated semantics for region-scoped operations - Removed `ReplaceBody` operation — use `Replace` with `region: ChunkRegion.Body` to replace only chunk body content diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 1135a234a..6b35ddb68 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -444,7 +444,7 @@ export interface ChunkReadTarget { export declare enum ChunkRegion { Head = 'head', - Inner = 'inner', + Body = 'body', Tail = 'tail' } diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 1673916e5..95fe22211 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -259,7 +259,7 @@ exports.ChunkReadStatus = { }; exports.ChunkRegion = { Head: 'head', - Inner: 'inner', + Body: 'body', Tail: 'tail', }; exports.Ellipsis = {