Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:15:46.016Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

hashed_regions.rs

1258 lines · 48.1 KB · rust
1//! Hashed Regions (V0609HashedRegions): a variant of the Smart Regions
2//! multi-region format where marker tags are identified by a short
3//! content-derived hash (e.g. `<|marker_b1f8|>`) instead of a sequence
4//! number.
5//!
6//! Hashed identifiers are self-describing: a tag can be mapped back to its
7//! location without reproducing the exact rendering order of the prompt, so
8//! markers can be placed across *all* prompt context, and budget-based
9//! truncation of related files doesn't shift the addressing of the remaining
10//! markers. All context, including the current file, lives in related files:
11//! context retrieval includes the current file via `ContextSource::CurrentFile`,
12//! so the cursor file is expected to be one of the related files. Inputs that
13//! weren't run through current-file retrieval can be normalized with
14//! [`ensure_cursor_file_excerpt`] before rendering or parsing.
15
16use crate::{ContextSource, RelatedExcerpt, RelatedFile, Zeta2PromptInput, multi_region, udiff};
17use anyhow::{Context as _, Result, anyhow};
18use std::{
19    borrow::Cow,
20    collections::{HashMap, HashSet},
21    ops::Range,
22    path::{Path, PathBuf},
23};
24
25pub const MARKER_TAG_PREFIX: &str = "<|marker_";
26pub const MARKER_TAG_SUFFIX: &str = "|>";
27pub const V0615_END_MARKER: &str = "<[end▁of▁sentence]>";
28pub const NO_EDITS: &str = "NO_EDITS";
29/// Number of base64 characters in a marker tag identifier.
30pub const TAG_ID_LEN: usize = 4;
31
32const BASE64_URL_SAFE_ALPHABET: &[u8; 64] =
33    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
34
35pub fn marker_tag(id: &str) -> String {
36    format!("{MARKER_TAG_PREFIX}{id}{MARKER_TAG_SUFFIX}")
37}
38
39/// Marker tags assigned to one contiguous snippet of context.
40#[derive(Debug, Clone)]
41pub struct SnippetMarkers {
42    pub file_ix: usize,
43    pub excerpt_ix: usize,
44    /// `(tag id, byte offset within the snippet text)`, sorted by offset.
45    /// The first marker is at offset 0 and the last at `text.len()`.
46    pub markers: Vec<(String, usize)>,
47}
48
49/// Assign hashed marker tags to every related-file excerpt of `input`.
50///
51/// The assignment is deterministic and independent of any later budget-based
52/// truncation, so the same table can be rebuilt when parsing model output.
53pub fn build_marker_table(input: &Zeta2PromptInput) -> Vec<SnippetMarkers> {
54    build_marker_table_with_filter(input, |_| true)
55}
56
57pub fn build_editable_marker_table(input: &Zeta2PromptInput) -> Vec<SnippetMarkers> {
58    build_marker_table_with_filter(input, is_hash_region_editable_context_source)
59}
60
61pub fn is_hash_region_editable_context_source(context_source: ContextSource) -> bool {
62    matches!(
63        context_source,
64        ContextSource::CurrentFile | ContextSource::EditHistory
65    )
66}
67
68fn build_marker_table_with_filter(
69    input: &Zeta2PromptInput,
70    include_context_source: impl Fn(ContextSource) -> bool,
71) -> Vec<SnippetMarkers> {
72    let mut used_ids = HashSet::new();
73    let mut snippets = Vec::new();
74    if let Some(related_files) = input.related_files.as_deref() {
75        for (file_ix, file) in related_files.iter().enumerate() {
76            for (excerpt_ix, excerpt) in file.excerpts.iter().enumerate() {
77                if include_context_source(excerpt.context_source) {
78                    snippets.push(SnippetMarkers {
79                        file_ix,
80                        excerpt_ix,
81                        markers: assign_tags(&excerpt.text, &mut used_ids),
82                    });
83                }
84            }
85        }
86    }
87    snippets
88}
89
90pub fn markers_for_text(text: &str) -> Vec<(String, usize)> {
91    let mut used_ids = HashSet::new();
92    assign_tags(text, &mut used_ids)
93}
94
95fn assign_tags(text: &str, used_ids: &mut HashSet<String>) -> Vec<(String, usize)> {
96    let offsets = multi_region::compute_marker_offsets_v0618(text);
97    offsets
98        .iter()
99        .enumerate()
100        .map(|(i, &offset)| {
101            let block = match offsets.get(i + 1) {
102                Some(&next_offset) => &text[offset..next_offset],
103                // The final marker has no following block; hash the preceding
104                // one. This collides with the previous marker's tag by
105                // construction, which `unique_tag_id` resolves by reseeding.
106                None => {
107                    let previous_offset = if i == 0 { 0 } else { offsets[i - 1] };
108                    &text[previous_offset..offset]
109                }
110            };
111            (unique_tag_id(block, used_ids), offset)
112        })
113        .collect()
114}
115
116fn unique_tag_id(content: &str, used_ids: &mut HashSet<String>) -> String {
117    let mut seed = 0u64;
118    loop {
119        let id = encode_tag_id(hash_with_seed(content, seed));
120        if used_ids.insert(id.clone()) {
121            return id;
122        }
123        seed += 1;
124    }
125}
126
127/// FNV-1a, with the seed folded in ahead of the content.
128fn hash_with_seed(content: &str, seed: u64) -> u64 {
129    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
130    const FNV_PRIME: u64 = 0x100000001b3;
131    let mut hash = FNV_OFFSET;
132    for byte in seed.to_le_bytes() {
133        hash ^= byte as u64;
134        hash = hash.wrapping_mul(FNV_PRIME);
135    }
136    for byte in content.as_bytes() {
137        hash ^= *byte as u64;
138        hash = hash.wrapping_mul(FNV_PRIME);
139    }
140    hash
141}
142
143fn encode_tag_id(hash: u64) -> String {
144    (0..TAG_ID_LEN)
145        .map(|i| BASE64_URL_SAFE_ALPHABET[((hash >> (6 * i)) & 0x3f) as usize] as char)
146        .collect()
147}
148
149/// Write `text` into `output`, inserting marker tags at the given offsets.
150/// When `cursor` is provided, its marker string is inserted at the given byte
151/// offset within `text`.
152pub fn write_snippet_with_markers(
153    output: &mut String,
154    text: &str,
155    markers: &[(String, usize)],
156    cursor: Option<(usize, &str)>,
157) {
158    let mut cursor_placed = false;
159    for (i, (id, offset)) in markers.iter().enumerate() {
160        if !output.is_empty() && !output.ends_with('\n') {
161            output.push('\n');
162        }
163        output.push_str(&marker_tag(id));
164
165        if let Some((_, next_offset)) = markers.get(i + 1) {
166            output.push('\n');
167            let block = &text[*offset..*next_offset];
168            match cursor {
169                Some((cursor_offset, cursor_marker))
170                    if !cursor_placed
171                        && cursor_offset >= *offset
172                        && cursor_offset <= *next_offset =>
173                {
174                    cursor_placed = true;
175                    let cursor_in_block = cursor_offset - offset;
176                    output.push_str(&block[..cursor_in_block]);
177                    output.push_str(cursor_marker);
178                    output.push_str(&block[cursor_in_block..]);
179                }
180                _ => output.push_str(block),
181            }
182        }
183    }
184}
185
186/// Extract a marker-bounded span from a model-output codeblock.
187///
188/// Returns `(start tag id, end tag id, content)` where `content` is the text
189/// between the first and last marker tags, with any intermediate marker tags
190/// stripped.
191pub fn extract_marker_span(text: &str) -> Result<(String, String, String)> {
192    let (start_id, end_id, content) = extract_marker_span_allow_same(text)?;
193    if start_id == end_id {
194        return Err(anyhow!(
195            "start and end markers are the same (marker {start_id})"
196        ));
197    }
198    Ok((start_id, end_id, content))
199}
200
201pub fn extract_marker_span_allow_same(text: &str) -> Result<(String, String, String)> {
202    let first_tag_start = text
203        .find(MARKER_TAG_PREFIX)
204        .context("no start marker found in output")?;
205    let first_id_start = first_tag_start + MARKER_TAG_PREFIX.len();
206    let first_id_end = text[first_id_start..]
207        .find(MARKER_TAG_SUFFIX)
208        .map(|i| i + first_id_start)
209        .context("malformed start marker tag")?;
210    let start_id = &text[first_id_start..first_id_end];
211    let first_tag_end = first_id_end + MARKER_TAG_SUFFIX.len();
212
213    let last_tag_start = text
214        .rfind(MARKER_TAG_PREFIX)
215        .context("no end marker found in output")?;
216    if last_tag_start == first_tag_start {
217        return Err(anyhow!("output span must be bounded by two marker tags"));
218    }
219    let last_id_start = last_tag_start + MARKER_TAG_PREFIX.len();
220    let last_id_end = text[last_id_start..]
221        .find(MARKER_TAG_SUFFIX)
222        .map(|i| i + last_id_start)
223        .context("malformed end marker tag")?;
224    let end_id = &text[last_id_start..last_id_end];
225
226    let mut content_start = first_tag_end;
227    if text.as_bytes().get(content_start) == Some(&b'\n') {
228        content_start += 1;
229    }
230    let content_end = last_tag_start;
231    let content = &text[content_start..content_end.max(content_start)];
232    let content = multi_region::strip_marker_tags(content);
233    Ok((start_id.to_string(), end_id.to_string(), content))
234}
235
236pub struct RelatedFileCursor {
237    pub file_ix: usize,
238    pub excerpt_ix: usize,
239    pub offset_in_excerpt: usize,
240}
241
242struct ParseSnippet<'a> {
243    file_ix: usize,
244    first_excerpt_ix: usize,
245    last_excerpt_ix: usize,
246    end_row: u32,
247    text: Cow<'a, str>,
248    markers: Vec<(String, usize)>,
249}
250
251pub fn related_file_patch_path(cursor_path: &Path, related_path: &Path) -> PathBuf {
252    let stripped: PathBuf = related_path.iter().skip(1).collect();
253    if stripped == cursor_path {
254        return stripped;
255    }
256
257    let cursor_first_component = cursor_path.components().next();
258    let related_first_component = related_path.components().next();
259    if related_first_component.is_some()
260        && cursor_first_component != related_first_component
261        && related_path.components().count() > 1
262    {
263        stripped
264    } else {
265        related_path.to_path_buf()
266    }
267}
268
269fn line_start_offset(text: &str, row: usize) -> Option<usize> {
270    let mut offset = 0;
271    for _ in 0..row {
272        offset += text[offset..].find('\n')? + 1;
273    }
274    Some(offset)
275}
276
277pub fn locate_cursor_in_related_files(input: &Zeta2PromptInput) -> Option<RelatedFileCursor> {
278    let related_files = input.related_files.as_deref()?;
279    let excerpt_start_row = input.excerpt_start_row?;
280    let cursor_offset = input.cursor_offset_in_excerpt;
281    let excerpt_prefix = input.cursor_excerpt.get(..cursor_offset)?;
282    let cursor_row = excerpt_start_row + excerpt_prefix.matches('\n').count() as u32;
283    let cursor_column = cursor_offset - excerpt_prefix.rfind('\n').map_or(0, |pos| pos + 1);
284
285    for (file_ix, file) in related_files.iter().enumerate() {
286        if related_file_patch_path(&input.cursor_path, &file.path) != input.cursor_path.as_ref() {
287            continue;
288        }
289
290        for (excerpt_ix, excerpt) in file.excerpts.iter().enumerate() {
291            if cursor_row < excerpt.row_range.start || cursor_row > excerpt.row_range.end {
292                continue;
293            }
294            let row_in_excerpt = (cursor_row - excerpt.row_range.start) as usize;
295            let line_start = line_start_offset(&excerpt.text, row_in_excerpt)?;
296            let line_len = excerpt.text[line_start..]
297                .lines()
298                .next()
299                .unwrap_or("")
300                .len();
301            if cursor_column <= line_len {
302                return Some(RelatedFileCursor {
303                    file_ix,
304                    excerpt_ix,
305                    offset_in_excerpt: line_start + cursor_column,
306                });
307            }
308        }
309    }
310
311    None
312}
313
314/// Ensure the cursor file is represented by a related-file excerpt that covers
315/// the cursor, synthesizing one from `cursor_excerpt` when it isn't.
316///
317/// All hashed-region context — including the current file — is addressed
318/// through `related_files` (see module docs), so a prompt built from a
319/// `Zeta2PromptInput` whose `related_files` don't cover the cursor cannot be
320/// rendered or parsed. Inputs produced by current-file context retrieval
321/// (`ContextSource::CurrentFile`) are already covered and left untouched; this
322/// normalizes the rest (e.g. raw settled-data samples, or any caller that
323/// didn't run current-file retrieval) from the `cursor_excerpt` the input
324/// already carries, so the format is usable without re-running context
325/// collection.
326///
327/// When synthesis is needed, any pre-existing excerpts of the cursor file are
328/// **replaced** by the synthesized window: the renderer emits excerpts verbatim
329/// without coalescing, so keeping overlapping fragments would duplicate lines
330/// with conflicting markers. Other related files are left untouched.
331///
332/// Returns whether the cursor file is covered after the call (already, or via
333/// the synthesized excerpt). Returns `false` only when coverage couldn't be
334/// established — e.g. a missing `excerpt_start_row` or an empty
335/// `cursor_excerpt` — in which case the input is left unchanged.
336pub fn ensure_cursor_file_excerpt(input: &mut Zeta2PromptInput) -> bool {
337    if locate_cursor_in_related_files(input).is_some() {
338        return true;
339    }
340    let Some(excerpt_start_row) = input.excerpt_start_row else {
341        return false;
342    };
343    if input.cursor_excerpt.is_empty() {
344        return false;
345    }
346
347    let cursor_excerpt = input.cursor_excerpt.clone();
348    let end_row = excerpt_start_row + cursor_excerpt.matches('\n').count() as u32;
349    let synthesized = RelatedExcerpt {
350        row_range: excerpt_start_row..end_row,
351        text: cursor_excerpt,
352        order: 0,
353        context_source: ContextSource::CurrentFile,
354    };
355
356    let cursor_path = input.cursor_path.clone();
357    let in_open_source_repo = input.in_open_source_repo;
358    let related_files = input.related_files.get_or_insert_with(Vec::new);
359    if let Some(file) = related_files
360        .iter_mut()
361        .find(|file| related_file_patch_path(&cursor_path, &file.path) == cursor_path.as_ref())
362    {
363        file.max_row = file.max_row.max(end_row);
364        file.excerpts = vec![synthesized];
365    } else {
366        related_files.insert(
367            0,
368            RelatedFile {
369                path: cursor_path,
370                max_row: end_row,
371                excerpts: vec![synthesized],
372                in_open_source_repo,
373            },
374        );
375    }
376
377    // Confirm the synthesized excerpt actually covers the cursor (guards against
378    // a cursor offset that lies outside the excerpt text).
379    locate_cursor_in_related_files(input).is_some()
380}
381
382pub fn marker_table_for_excerpt(
383    marker_table: &[SnippetMarkers],
384    file_ix: usize,
385    excerpt_ix: usize,
386) -> Option<&[(String, usize)]> {
387    marker_table.iter().find_map(|snippet| {
388        (snippet.file_ix == file_ix && snippet.excerpt_ix == excerpt_ix)
389            .then_some(snippet.markers.as_slice())
390    })
391}
392
393fn merge_contiguous_snippets(
394    input: &Zeta2PromptInput,
395    marker_table: Vec<SnippetMarkers>,
396) -> Result<Vec<ParseSnippet<'_>>> {
397    let related_files = input
398        .related_files
399        .as_deref()
400        .context("prompt inputs are missing related files")?;
401    let mut snippets: Vec<ParseSnippet> = Vec::new();
402    for snippet in marker_table {
403        let file = related_files
404            .get(snippet.file_ix)
405            .context("related file index out of range")?;
406        let excerpt = file
407            .excerpts
408            .get(snippet.excerpt_ix)
409            .context("related excerpt index out of range")?;
410        if let Some(last) = snippets.last_mut()
411            && last.file_ix == snippet.file_ix
412            && last.last_excerpt_ix + 1 == snippet.excerpt_ix
413            && last.end_row == excerpt.row_range.start
414        {
415            let text = last.text.to_mut();
416            if !text.is_empty() && !text.ends_with('\n') {
417                text.push('\n');
418            }
419            let base = text.len();
420            text.push_str(&excerpt.text);
421            last.markers.extend(
422                snippet
423                    .markers
424                    .into_iter()
425                    .map(|(id, offset)| (id, base + offset)),
426            );
427            last.last_excerpt_ix = snippet.excerpt_ix;
428            last.end_row = excerpt.row_range.end;
429        } else {
430            snippets.push(ParseSnippet {
431                file_ix: snippet.file_ix,
432                first_excerpt_ix: snippet.excerpt_ix,
433                last_excerpt_ix: snippet.excerpt_ix,
434                end_row: excerpt.row_range.end,
435                text: Cow::Borrowed(excerpt.text.as_ref()),
436                markers: snippet.markers,
437            });
438        }
439    }
440    Ok(snippets)
441}
442
443fn snippet_path_and_start_row(
444    input: &Zeta2PromptInput,
445    snippet: &ParseSnippet<'_>,
446) -> Result<(PathBuf, u32)> {
447    let related_files = input
448        .related_files
449        .as_deref()
450        .context("prompt inputs are missing related files")?;
451    let file = related_files
452        .get(snippet.file_ix)
453        .context("related file index out of range")?;
454    let excerpt = file
455        .excerpts
456        .get(snippet.first_excerpt_ix)
457        .context("related excerpt index out of range")?;
458    Ok((
459        related_file_patch_path(&input.cursor_path, &file.path),
460        excerpt.row_range.start,
461    ))
462}
463
464fn common_prefix_suffix(a: &[u8], b: &[u8]) -> (usize, usize) {
465    let prefix = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
466    let remaining_a = a.len() - prefix;
467    let remaining_b = b.len() - prefix;
468    let max_suffix = remaining_a.min(remaining_b);
469    let suffix = a[a.len() - max_suffix..]
470        .iter()
471        .rev()
472        .zip(b[b.len() - max_suffix..].iter().rev())
473        .take_while(|(x, y)| x == y)
474        .count();
475    (prefix, suffix)
476}
477
478fn nearest_marker_id(markers: &[(String, usize)], cursor_offset: Option<usize>) -> &str {
479    let cursor = cursor_offset.unwrap_or(0);
480    markers
481        .iter()
482        .min_by_key(|(_, offset)| (*offset as isize - cursor as isize).unsigned_abs())
483        .map(|(id, _)| id.as_str())
484        .unwrap_or("unknown")
485}
486
487/// Encode a single marker-bounded edit block for one snippet, given its old and
488/// new text. The returned block starts and ends with a marker tag and does
489/// **not** include the output end marker; callers concatenate blocks and append
490/// [`V0615_END_MARKER`] once after the last block.
491pub fn encode_from_old_and_new(
492    old_text: &str,
493    new_text: &str,
494    markers: &[(String, usize)],
495    cursor_offset_in_new: Option<usize>,
496    cursor_marker: &str,
497) -> Result<String> {
498    let no_edit_id = nearest_marker_id(markers, cursor_offset_in_new);
499    if old_text == new_text {
500        let tag = marker_tag(no_edit_id);
501        return Ok(format!("{tag}{tag}"));
502    }
503
504    let (common_prefix, common_suffix) =
505        common_prefix_suffix(old_text.as_bytes(), new_text.as_bytes());
506    let change_end_in_old = old_text.len() - common_suffix;
507    let mut start_marker_ix = markers
508        .iter()
509        .rposition(|(_, offset)| *offset <= common_prefix)
510        .unwrap_or(0);
511    let mut end_marker_ix = markers
512        .iter()
513        .position(|(_, offset)| *offset >= change_end_in_old)
514        .unwrap_or_else(|| markers.len().saturating_sub(1));
515
516    if start_marker_ix == end_marker_ix {
517        if end_marker_ix < markers.len().saturating_sub(1) {
518            end_marker_ix += 1;
519        } else if start_marker_ix > 0 {
520            start_marker_ix -= 1;
521        }
522    }
523
524    let old_start = markers
525        .get(start_marker_ix)
526        .map(|(_, offset)| *offset)
527        .context("start marker out of range")?;
528    let old_end = markers
529        .get(end_marker_ix)
530        .map(|(_, offset)| *offset)
531        .context("end marker out of range")?;
532    let new_start = old_start;
533    let new_end = new_text
534        .len()
535        .saturating_sub(old_text.len().saturating_sub(old_end));
536    let new_span = &new_text[new_start..new_end];
537
538    let mut result = String::new();
539    result.push_str(&marker_tag(&markers[start_marker_ix].0));
540    result.push('\n');
541    if let Some(cursor_offset) = cursor_offset_in_new {
542        if cursor_offset >= new_start && cursor_offset <= new_end {
543            let cursor_in_span = cursor_offset - new_start;
544            result.push_str(&new_span[..cursor_in_span]);
545            result.push_str(cursor_marker);
546            result.push_str(&new_span[cursor_in_span..]);
547        } else {
548            result.push_str(new_span);
549        }
550    } else {
551        result.push_str(new_span);
552    }
553    if !result.ends_with('\n') {
554        result.push('\n');
555    }
556    result.push_str(&marker_tag(&markers[end_marker_ix].0));
557    Ok(result)
558}
559
560/// Parse student model output (raw marker spans, no markdown code fences) into
561/// a unified patch.
562///
563/// The output is a run of marker tags with no fences, so blocks are delimited by
564/// pairing tags two at a time: `(1, 2), (3, 4), ...`. This matches the encoder,
565/// which emits exactly two tags per block and no intermediate tags. Any
566/// unpaired trailing tag is ignored.
567pub fn parse_output_as_patch(
568    input: &Zeta2PromptInput,
569    output: &str,
570    cursor_marker: &str,
571) -> Result<String> {
572    let output = output.strip_suffix(V0615_END_MARKER).unwrap_or(output);
573    if output.trim() == NO_EDITS {
574        return Ok(String::new());
575    }
576
577    let spans = pair_marker_spans(output)?;
578    let (patch, _cursor) = build_patch_from_spans(input, &spans, cursor_marker)?;
579    Ok(patch)
580}
581
582/// A cursor position resolved while turning marker-span edits into a patch.
583pub struct HashRegionCursor {
584    pub path: PathBuf,
585    /// Byte offset of the cursor within `new_text`.
586    pub cursor_offset_in_new_text: usize,
587    /// Full new text of the edited snippet, after applying all of its edits.
588    pub new_text: String,
589    /// Original text of the edited snippet.
590    pub old_text: String,
591    /// 0-based row where the snippet starts in its file.
592    pub start_row: u32,
593}
594
595/// One marker-bounded edit resolved against a parse snippet.
596struct ParsedSpanEdit {
597    snippet_ix: usize,
598    range: Range<usize>,
599    new_text: String,
600    cursor_offset_in_new_text: Option<usize>,
601}
602
603/// Split raw model output into marker-bounded spans by pairing marker tags two
604/// at a time. Returns `(start_id, end_id, raw_new_span)` per pair, where
605/// `raw_new_span` may still contain the cursor marker.
606fn pair_marker_spans(output: &str) -> Result<Vec<(String, String, String)>> {
607    let tags = find_all_marker_tags(output);
608    if tags.len() < 2 {
609        return Err(anyhow!("output does not contain a marker-bounded span"));
610    }
611    let mut spans = Vec::new();
612    let mut i = 0;
613    while i + 1 < tags.len() {
614        let (start_id, _, start_tag_end) = &tags[i];
615        let (end_id, end_tag_start, _) = &tags[i + 1];
616        let content = &output[*start_tag_end..*end_tag_start];
617        let content = content.strip_prefix('\n').unwrap_or(content);
618        let content = multi_region::strip_marker_tags(content);
619        spans.push((start_id.clone(), end_id.clone(), content));
620        i += 2;
621    }
622    Ok(spans)
623}
624
625/// Find every marker tag in `text`, in order, as `(id, tag_start, tag_end)`.
626fn find_all_marker_tags(text: &str) -> Vec<(String, usize, usize)> {
627    let mut tags = Vec::new();
628    let mut search = 0;
629    while let Some(rel) = text[search..].find(MARKER_TAG_PREFIX) {
630        let tag_start = search + rel;
631        let id_start = tag_start + MARKER_TAG_PREFIX.len();
632        let Some(suffix_rel) = text[id_start..].find(MARKER_TAG_SUFFIX) else {
633            break;
634        };
635        let id_end = id_start + suffix_rel;
636        let tag_end = id_end + MARKER_TAG_SUFFIX.len();
637        tags.push((text[id_start..id_end].to_string(), tag_start, tag_end));
638        search = tag_end;
639    }
640    tags
641}
642
643/// Resolve a list of marker spans into per-snippet edits and assemble a unified
644/// patch.
645///
646/// `spans` is a list of `(start_id, end_id, raw_new_span)` where `raw_new_span`
647/// may still contain `cursor_marker`. This is shared by the student parser
648/// (which pairs raw marker tags) and the teacher parser (which extracts spans
649/// from markdown code fences). Edits that overlap an already-accepted edit in
650/// the same snippet are skipped (lenient). The cursor marker is honored in
651/// every region that contains it; the returned [`HashRegionCursor`] reports the
652/// first such position.
653pub fn build_patch_from_spans(
654    input: &Zeta2PromptInput,
655    spans: &[(String, String, String)],
656    cursor_marker: &str,
657) -> Result<(String, Option<HashRegionCursor>)> {
658    let marker_table = build_marker_table(input);
659    let snippets = merge_contiguous_snippets(input, marker_table)?;
660    let mut marker_index: HashMap<&str, (usize, usize)> = HashMap::new();
661    for (snippet_ix, snippet) in snippets.iter().enumerate() {
662        for (id, offset) in &snippet.markers {
663            marker_index.insert(id.as_str(), (snippet_ix, *offset));
664        }
665    }
666
667    let mut edits: Vec<ParsedSpanEdit> = Vec::new();
668    for (start_id, end_id, raw_new_span) in spans {
669        let &(start_snippet, start_byte) = marker_index
670            .get(start_id.as_str())
671            .with_context(|| format!("unknown start marker `{start_id}`"))?;
672        let &(end_snippet, end_byte) = marker_index
673            .get(end_id.as_str())
674            .with_context(|| format!("unknown end marker `{end_id}`"))?;
675
676        if start_snippet != end_snippet {
677            return Err(anyhow!(
678                "markers `{start_id}` and `{end_id}` belong to different context snippets \
679                 that are not contiguous excerpts of the same file"
680            ));
681        }
682        if start_byte > end_byte {
683            return Err(anyhow!(
684                "start marker `{start_id}` must come before end marker `{end_id}`"
685            ));
686        }
687
688        let old_text = snippets[start_snippet].text.as_ref();
689        let old_span = &old_text[start_byte..end_byte];
690
691        let cursor_in_span = raw_new_span.find(cursor_marker);
692        let mut new_span = raw_new_span.replace(cursor_marker, "");
693        if old_span.is_empty() {
694            if !new_span.is_empty() && !new_span.ends_with('\n') {
695                new_span.push('\n');
696            }
697        } else {
698            if old_span.ends_with('\n') && !new_span.ends_with('\n') && !new_span.is_empty() {
699                new_span.push('\n');
700            }
701            if !old_span.ends_with('\n') && new_span.ends_with('\n') {
702                new_span.pop();
703            }
704        }
705
706        if !new_span.is_empty()
707            && let Some(dropped) = detect_trailing_deletion(old_span, &new_span)
708        {
709            return Err(anyhow!(
710                "edit span `{start_id}`..`{end_id}` looks truncated: the replacement \
711                 stops before the end marker, which would silently delete:\n{dropped}"
712            ));
713        }
714
715        // `cursor_in_span` was located in `raw_new_span` before the trailing
716        // newline normalization above, which can drop a byte. Clamp it to the
717        // finalized replacement so the offset never points past `new_span`
718        // (downstream cursor mapping byte-slices `new_text` by this offset).
719        let cursor_offset_in_new_text = cursor_in_span.map(|offset| offset.min(new_span.len()));
720        edits.push(ParsedSpanEdit {
721            snippet_ix: start_snippet,
722            range: start_byte..end_byte,
723            new_text: new_span,
724            cursor_offset_in_new_text,
725        });
726    }
727
728    assemble_patch_from_edits(input, &snippets, edits)
729}
730
731/// Apply resolved edits to their snippets and emit one diff section per edited
732/// snippet, in the order snippets first appear in the edit sequence.
733fn assemble_patch_from_edits(
734    input: &Zeta2PromptInput,
735    snippets: &[ParseSnippet<'_>],
736    edits: Vec<ParsedSpanEdit>,
737) -> Result<(String, Option<HashRegionCursor>)> {
738    let mut snippet_order: Vec<usize> = Vec::new();
739    for edit in &edits {
740        if !snippet_order.contains(&edit.snippet_ix) {
741            snippet_order.push(edit.snippet_ix);
742        }
743    }
744
745    let mut diff_output = String::new();
746    let mut cursor = None;
747
748    for &snippet_ix in &snippet_order {
749        let snippet = &snippets[snippet_ix];
750        let mut snippet_edits: Vec<&ParsedSpanEdit> = edits
751            .iter()
752            .filter(|edit| edit.snippet_ix == snippet_ix)
753            .collect();
754        snippet_edits.sort_by_key(|edit| edit.range.start);
755
756        // Lenient overlap handling: keep edits in line order, dropping any whose
757        // range starts before the previous accepted edit ended.
758        let mut accepted: Vec<&ParsedSpanEdit> = Vec::new();
759        let mut last_end = 0usize;
760        for edit in snippet_edits {
761            if !accepted.is_empty() && edit.range.start < last_end {
762                continue;
763            }
764            last_end = edit.range.end;
765            accepted.push(edit);
766        }
767
768        let old_text = snippet.text.as_ref();
769        let (path, start_row) = snippet_path_and_start_row(input, snippet)?;
770
771        let mut new_text = String::new();
772        let mut position = 0;
773        let mut cursor_in_new_text = None;
774        for edit in &accepted {
775            new_text.push_str(&old_text[position..edit.range.start]);
776            if let Some(cursor_offset) = edit.cursor_offset_in_new_text {
777                cursor_in_new_text = Some(new_text.len() + cursor_offset);
778            }
779            new_text.push_str(&edit.new_text);
780            position = edit.range.end;
781        }
782        new_text.push_str(&old_text[position..]);
783
784        let diff = udiff::unified_diff_with_context(old_text, &new_text, start_row, start_row, 3);
785        if !diff.is_empty() {
786            let path_str = path
787                .iter()
788                .map(|component| component.to_string_lossy())
789                .collect::<Vec<_>>()
790                .join("/");
791            diff_output.push_str(&format!("--- a/{path_str}\n+++ b/{path_str}\n"));
792            diff_output.push_str(&diff);
793            if !diff_output.ends_with('\n') {
794                diff_output.push('\n');
795            }
796        }
797
798        if cursor.is_none()
799            && let Some(cursor_offset) = cursor_in_new_text
800        {
801            cursor = Some(HashRegionCursor {
802                path: path.clone(),
803                cursor_offset_in_new_text: cursor_offset,
804                new_text: new_text.clone(),
805                old_text: old_text.to_string(),
806                start_row,
807            });
808        }
809    }
810
811    Ok((diff_output, cursor))
812}
813
814/// Detects a span replacement that ends in a pure deletion of the span's tail,
815/// the signature of a model that stopped writing before reaching its end
816/// marker.
817///
818/// Returns the deleted tail if the line diff between `old_span` and `new_span`
819/// ends with a deletion-only group that reaches the last line of `old_span` and
820/// drops more than `MAX_TRAILING_DELETED_LINES` non-blank lines.
821fn detect_trailing_deletion(old_span: &str, new_span: &str) -> Option<String> {
822    const MAX_TRAILING_DELETED_LINES: usize = 3;
823
824    fn flag_if_large(deleted_tail: &str) -> Option<String> {
825        let non_blank_deleted = deleted_tail
826            .lines()
827            .filter(|line| !line.trim().is_empty())
828            .count();
829        (non_blank_deleted > MAX_TRAILING_DELETED_LINES)
830            .then(|| deleted_tail.trim_end().to_string())
831    }
832
833    // A verbatim prefix is checked at the byte level so that a replacement
834    // stopping mid-line is caught too; the line diff below would see that as a
835    // trailing replace group rather than a pure deletion.
836    if let Some(deleted_tail) = old_span.strip_prefix(new_span) {
837        return flag_if_large(deleted_tail);
838    }
839
840    // With zero context lines, hunks contain only `-` and `+` lines, and within
841    // a hunk deletions precede insertions, so a diff whose final line is a
842    // deletion ends with a deletion-only group.
843    let diff = udiff::unified_diff_with_context(old_span, new_span, 0, 0, 0);
844    let lines: Vec<&str> = diff.lines().collect();
845    let mut deletion_start = lines.len();
846    while deletion_start > 0 && lines[deletion_start - 1].starts_with('-') {
847        deletion_start -= 1;
848    }
849    let deleted: Vec<&str> = lines[deletion_start..]
850        .iter()
851        .map(|line| line.strip_prefix('-').unwrap_or(line))
852        .collect();
853    if deleted.is_empty() {
854        return None;
855    }
856
857    // The trailing `-` run is preceded by its hunk header exactly when the hunk
858    // is deletion-only (a replacement group would interpose `+` lines).
859    let header = lines.get(deletion_start.checked_sub(1)?)?;
860    let old_range_start: usize = header
861        .strip_prefix("@@ -")?
862        .split(',')
863        .next()?
864        .parse()
865        .ok()?;
866
867    // Only flag deletions that reach the end of the span; a deletion in the
868    // middle is followed by reproduced context, so the model demonstrably kept
869    // writing past it.
870    if old_range_start + deleted.len() - 1 != old_span.lines().count() {
871        return None;
872    }
873
874    flag_if_large(&deleted.join("\n"))
875}
876
877/// Encode an expected unified patch into the training output for a student.
878///
879/// Emits **one marker-bounded block per diff hunk**, in patch order (which
880/// preserves the teacher's cross-file ordering), with no markdown code fences,
881/// terminated by a single [`V0615_END_MARKER`]. Blocks are separated by a
882/// newline; the parser re-pairs marker tags two at a time, so the separator is
883/// not significant.
884///
885/// Reachability is per hunk: a hunk whose file is absent from the prompt
886/// context, or whose location can't be resolved within its snippet, is skipped.
887/// If at least one hunk is reachable, the remaining hunks are still encoded
888/// (partial edit). If no hunk is reachable, the output is `NO_EDITS`.
889pub fn encode_patch_as_output(
890    input: &Zeta2PromptInput,
891    patch: &str,
892    cursor_offset: Option<usize>,
893    cursor_marker: &str,
894) -> Result<String> {
895    if patch.lines().count() <= 3 {
896        return Ok(format!("{NO_EDITS}{V0615_END_MARKER}"));
897    }
898
899    let marker_table = build_marker_table(input);
900    let snippets = merge_contiguous_snippets(input, marker_table)?;
901    let mut parser = udiff::DiffParser::new(patch);
902    let mut blocks: Vec<String> = Vec::new();
903
904    while let Some(event) = parser.next().context("failed to parse expected patch")? {
905        let udiff::DiffEvent::Hunk {
906            path,
907            mut hunk,
908            status: _,
909        } = event
910        else {
911            continue;
912        };
913
914        // A hunk whose file isn't in the prompt context is unreachable; skip it
915        // and keep any other reachable hunks (partial edit).
916        let Some((snippet_ix, start_row)) =
917            snippets
918                .iter()
919                .enumerate()
920                .find_map(|(snippet_ix, snippet)| {
921                    let (snippet_path, start_row) =
922                        snippet_path_and_start_row(input, snippet).ok()?;
923                    (snippet_path == Path::new(path.as_ref())).then_some((snippet_ix, start_row))
924                })
925        else {
926            continue;
927        };
928        let snippet = &snippets[snippet_ix];
929        let old_text = snippet.text.as_ref();
930        let candidates = udiff::find_context_candidates(old_text, &mut hunk);
931        // A hunk whose location can't be pinned down within the snippet is
932        // unreachable; skip it.
933        let Some(hunk_offset) =
934            udiff::disambiguate_by_line_number(&candidates, hunk.start_line, &|offset| {
935                start_row + old_text[..offset].matches('\n').count() as u32
936            })
937        else {
938            continue;
939        };
940
941        let mut new_text = old_text.to_string();
942        for edit in hunk.edits.iter().rev() {
943            let range = (hunk_offset + edit.range.start)..(hunk_offset + edit.range.end);
944            new_text.replace_range(range, &edit.text);
945        }
946        // The cursor marker is placed in every region whose span contains it.
947        // The extracted `cursor_offset` is hunk-relative, so map it through each
948        // hunk's offset; `encode_from_old_and_new` inserts it only when it lands
949        // within that block's span.
950        let cursor_in_new = cursor_offset.map(|cursor| (hunk_offset + cursor).min(new_text.len()));
951        blocks.push(encode_from_old_and_new(
952            old_text,
953            &new_text,
954            &snippet.markers,
955            cursor_in_new,
956            cursor_marker,
957        )?);
958    }
959
960    if blocks.is_empty() {
961        return Ok(format!("{NO_EDITS}{V0615_END_MARKER}"));
962    }
963
964    let mut output = blocks.join("\n");
965    output.push_str(V0615_END_MARKER);
966    Ok(output)
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972    use crate::{ContextSource, RelatedExcerpt, RelatedFile};
973    use std::path::PathBuf;
974    use std::sync::Arc;
975
976    fn make_input(cursor_excerpt: &str, related: &[(&str, &[&str])]) -> Zeta2PromptInput {
977        Zeta2PromptInput {
978            cursor_path: PathBuf::from("src/main.rs").into(),
979            cursor_excerpt: cursor_excerpt.into(),
980            cursor_offset_in_excerpt: 0,
981            excerpt_start_row: Some(0),
982            events: Vec::new(),
983            related_files: Some(
984                related
985                    .iter()
986                    .map(|(path, excerpts)| {
987                        let mut row = 0;
988                        RelatedFile {
989                            path: Arc::from(PathBuf::from(path).as_path()),
990                            max_row: 1000,
991                            excerpts: excerpts
992                                .iter()
993                                .map(|text| {
994                                    let row_count = text.matches('\n').count() as u32;
995                                    let excerpt = RelatedExcerpt {
996                                        row_range: row..row + row_count,
997                                        text: Arc::from(*text),
998                                        order: 0,
999                                        context_source: ContextSource::CurrentFile,
1000                                    };
1001                                    row += row_count + 10;
1002                                    excerpt
1003                                })
1004                                .collect(),
1005                            in_open_source_repo: false,
1006                        }
1007                    })
1008                    .collect(),
1009            ),
1010            active_buffer_diagnostics: Vec::new(),
1011            excerpt_ranges: crate::ExcerptRanges::default(),
1012            syntax_ranges: None,
1013            in_open_source_repo: false,
1014            can_collect_data: false,
1015            repo_url: None,
1016        }
1017    }
1018
1019    #[test]
1020    fn test_ensure_cursor_file_excerpt_synthesizes_when_uncovered() {
1021        // The cursor file's only related excerpt is a fragment elsewhere in the
1022        // file (rows 40..42), not covering the cursor at row 1.
1023        let mut input = make_input(
1024            "fn main() {\n    let x = 1;\n}\n",
1025            &[("src/main.rs", &["// unrelated\n// fragment\n"])],
1026        );
1027        input.cursor_offset_in_excerpt = 16; // inside "    let x = 1;"
1028        input.related_files.as_mut().unwrap()[0].excerpts[0].row_range = 40..42;
1029
1030        assert!(locate_cursor_in_related_files(&input).is_none());
1031        assert!(ensure_cursor_file_excerpt(&mut input));
1032
1033        let cursor =
1034            locate_cursor_in_related_files(&input).expect("cursor covered after synthesis");
1035        let file = &input.related_files.as_ref().unwrap()[cursor.file_ix];
1036        // The fragment was replaced by the synthesized full window, so the file
1037        // content isn't duplicated with overlapping markers.
1038        assert_eq!(file.excerpts.len(), 1);
1039        assert_eq!(file.excerpts[0].context_source, ContextSource::CurrentFile);
1040        assert_eq!(file.excerpts[0].row_range, 0..3);
1041        assert_eq!(
1042            file.excerpts[0].text.as_ref(),
1043            "fn main() {\n    let x = 1;\n}\n"
1044        );
1045    }
1046
1047    #[test]
1048    fn test_ensure_cursor_file_excerpt_noop_when_covered() {
1049        // make_input places the cursor file's excerpt at rows 0..3, covering the
1050        // cursor at row 1.
1051        let mut input = make_input(
1052            "fn main() {\n    let x = 1;\n}\n",
1053            &[("src/main.rs", &["fn main() {\n    let x = 1;\n}\n"])],
1054        );
1055        input.cursor_offset_in_excerpt = 16;
1056        let before = input.clone();
1057        assert!(ensure_cursor_file_excerpt(&mut input));
1058        assert_eq!(input, before);
1059    }
1060
1061    #[test]
1062    fn test_tag_ids_are_unique_even_for_identical_blocks() {
1063        let mut used = HashSet::new();
1064        let id_a = unique_tag_id("same content", &mut used);
1065        let id_b = unique_tag_id("same content", &mut used);
1066        assert_ne!(id_a, id_b);
1067        assert_eq!(id_a.len(), TAG_ID_LEN);
1068        assert_eq!(id_b.len(), TAG_ID_LEN);
1069    }
1070
1071    #[test]
1072    fn test_tag_ids_are_deterministic() {
1073        let mut used_a = HashSet::new();
1074        let mut used_b = HashSet::new();
1075        assert_eq!(
1076            unique_tag_id("hello\nworld\n", &mut used_a),
1077            unique_tag_id("hello\nworld\n", &mut used_b)
1078        );
1079    }
1080
1081    #[test]
1082    fn test_build_marker_table_covers_all_context() {
1083        let input = make_input(
1084            "fn main() {\n    println!();\n}\n",
1085            &[
1086                ("src/a.rs", &["struct A;\n", "impl A {}\n"]),
1087                ("src/b.rs", &["struct B;\n"]),
1088            ],
1089        );
1090        let table = build_marker_table(&input);
1091        assert_eq!(table.len(), 3);
1092        assert_eq!((table[0].file_ix, table[0].excerpt_ix), (0, 0));
1093        assert_eq!((table[1].file_ix, table[1].excerpt_ix), (0, 1));
1094        assert_eq!((table[2].file_ix, table[2].excerpt_ix), (1, 0));
1095
1096        let mut all_ids = HashSet::new();
1097        for snippet in &table {
1098            assert!(snippet.markers.len() >= 2);
1099            assert_eq!(snippet.markers.first().map(|(_, offset)| *offset), Some(0));
1100            for (id, _) in &snippet.markers {
1101                assert!(all_ids.insert(id.clone()), "duplicate tag id {id}");
1102            }
1103        }
1104    }
1105
1106    #[test]
1107    fn test_write_snippet_with_markers_and_cursor() {
1108        let text = "fn main() {\n    let x = 1;\n}\n";
1109        let markers = vec![("aaaa".to_string(), 0), ("bbbb".to_string(), text.len())];
1110        let mut output = String::new();
1111        write_snippet_with_markers(&mut output, text, &markers, Some((16, "<|user_cursor|>")));
1112        assert_eq!(
1113            output,
1114            "<|marker_aaaa|>\nfn main() {\n    <|user_cursor|>let x = 1;\n}\n<|marker_bbbb|>"
1115        );
1116    }
1117
1118    #[test]
1119    fn test_extract_marker_span_round_trip() {
1120        let codeblock = "<|marker_aaaa|>\nnew content\n<|marker_bbbb|>";
1121        let (start, end, content) = extract_marker_span(codeblock).unwrap();
1122        assert_eq!(start, "aaaa");
1123        assert_eq!(end, "bbbb");
1124        assert_eq!(content, "new content\n");
1125    }
1126
1127    #[test]
1128    fn test_extract_marker_span_strips_intermediate_tags() {
1129        let codeblock = "<|marker_aaaa|>\nline one\n<|marker_cccc|>\nline two\n<|marker_bbbb|>";
1130        let (start, end, content) = extract_marker_span(codeblock).unwrap();
1131        assert_eq!(start, "aaaa");
1132        assert_eq!(end, "bbbb");
1133        assert_eq!(content, "line one\nline two\n");
1134    }
1135
1136    #[test]
1137    fn test_extract_marker_span_rejects_single_marker() {
1138        assert!(extract_marker_span("<|marker_aaaa|>\ncontent\n").is_err());
1139    }
1140
1141    #[test]
1142    fn test_extract_marker_span_rejects_same_marker() {
1143        assert!(extract_marker_span("<|marker_aaaa|>\ncontent\n<|marker_aaaa|>").is_err());
1144    }
1145
1146    const MULTI_FN_EXCERPT: &str = "fn alpha() {\n    one();\n}\n\nfn beta() {\n    two();\n}\n\nfn gamma() {\n    three();\n}\n";
1147
1148    const TWO_HUNK_PATCH: &str = concat!(
1149        "--- a/src/main.rs\n",
1150        "+++ b/src/main.rs\n",
1151        "@@ -1,3 +1,3 @@\n",
1152        " fn alpha() {\n",
1153        "-    one();\n",
1154        "+    uno();\n",
1155        " }\n",
1156        "@@ -9,3 +9,3 @@\n",
1157        " fn gamma() {\n",
1158        "-    three();\n",
1159        "+    tres();\n",
1160        " }\n",
1161    );
1162
1163    #[test]
1164    fn test_encode_multi_hunk_emits_multiple_blocks() {
1165        let input = make_input(MULTI_FN_EXCERPT, &[("src/main.rs", &[MULTI_FN_EXCERPT])]);
1166        let output =
1167            encode_patch_as_output(&input, TWO_HUNK_PATCH, None, "<|user_cursor|>").unwrap();
1168
1169        assert!(output.ends_with(V0615_END_MARKER), "output: {output}");
1170        // Two blocks => four marker tags, exactly one end marker.
1171        assert_eq!(
1172            output.matches(MARKER_TAG_PREFIX).count(),
1173            4,
1174            "output: {output}"
1175        );
1176        assert_eq!(
1177            output.matches(V0615_END_MARKER).count(),
1178            1,
1179            "output: {output}"
1180        );
1181        assert!(output.contains("uno();"), "output: {output}");
1182        assert!(output.contains("tres();"), "output: {output}");
1183    }
1184
1185    #[test]
1186    fn test_round_trip_multi_hunk() {
1187        let input = make_input(MULTI_FN_EXCERPT, &[("src/main.rs", &[MULTI_FN_EXCERPT])]);
1188        let output =
1189            encode_patch_as_output(&input, TWO_HUNK_PATCH, None, "<|user_cursor|>").unwrap();
1190        let patch = parse_output_as_patch(&input, &output, "<|user_cursor|>").unwrap();
1191
1192        assert!(patch.contains("-    one();"), "patch: {patch}");
1193        assert!(patch.contains("+    uno();"), "patch: {patch}");
1194        assert!(patch.contains("-    three();"), "patch: {patch}");
1195        assert!(patch.contains("+    tres();"), "patch: {patch}");
1196    }
1197
1198    #[test]
1199    fn test_encode_partial_skips_unreachable_hunk() {
1200        // Second hunk targets a file that is not in the prompt context, so it
1201        // is unreachable. The first (reachable) hunk is still encoded.
1202        let patch = format!(
1203            "{TWO_HUNK_PATCH}--- a/other.rs\n+++ b/other.rs\n@@ -1,1 +1,1 @@\n-gone();\n+kept();\n"
1204        );
1205        let input = make_input(MULTI_FN_EXCERPT, &[("src/main.rs", &[MULTI_FN_EXCERPT])]);
1206        let output = encode_patch_as_output(&input, &patch, None, "<|user_cursor|>").unwrap();
1207
1208        assert_ne!(output.trim_end_matches(V0615_END_MARKER), NO_EDITS);
1209        assert!(output.contains("uno();"), "output: {output}");
1210        assert!(output.contains("tres();"), "output: {output}");
1211        assert!(!output.contains("kept();"), "output: {output}");
1212    }
1213
1214    #[test]
1215    fn test_encode_no_edits_when_all_hunks_unreachable() {
1216        let patch = "--- a/other.rs\n+++ b/other.rs\n@@ -1,3 +1,3 @@\n fn x() {\n-    gone();\n+    kept();\n }\n";
1217        let input = make_input(MULTI_FN_EXCERPT, &[("src/main.rs", &[MULTI_FN_EXCERPT])]);
1218        let output = encode_patch_as_output(&input, patch, None, "<|user_cursor|>").unwrap();
1219
1220        assert_eq!(output, format!("{NO_EDITS}{V0615_END_MARKER}"));
1221    }
1222
1223    #[test]
1224    fn test_parse_multiple_direct_marker_blocks() {
1225        // The student emits raw marker spans with no code fences; blocks are
1226        // delimited by pairing tags two at a time.
1227        let input = make_input(MULTI_FN_EXCERPT, &[("src/main.rs", &[MULTI_FN_EXCERPT])]);
1228        let markers = build_marker_table(&input)[0].markers.clone();
1229        assert!(markers.len() >= 3, "expected internal markers: {markers:?}");
1230
1231        let tag = |ix: usize| marker_tag(&markers[ix].0);
1232        let old_first = &MULTI_FN_EXCERPT[markers[0].1..markers[1].1];
1233        let old_second = &MULTI_FN_EXCERPT[markers[1].1..markers[markers.len() - 1].1];
1234        let new_first = old_first.replace("one()", "uno()");
1235        let new_second = old_second.replace("three()", "tres()");
1236
1237        let output = format!(
1238            "{}\n{}{}\n{}\n{}{}{}",
1239            tag(0),
1240            new_first,
1241            tag(1),
1242            tag(1),
1243            new_second,
1244            tag(markers.len() - 1),
1245            V0615_END_MARKER,
1246        );
1247
1248        let patch = parse_output_as_patch(&input, &output, "<|user_cursor|>").unwrap();
1249        assert!(patch.contains("+    uno();"), "patch: {patch}");
1250        assert!(patch.contains("+    tres();"), "patch: {patch}");
1251        assert_eq!(
1252            patch.matches("--- a/src/main.rs").count(),
1253            1,
1254            "patch: {patch}"
1255        );
1256    }
1257}
1258
Served at tenant.openagents/omega Member data and write actions are omitted.