1use std::{fmt::Write, ops::Range, path::Path, sync::Arc, time::Instant};
2
3use crate::{
4 DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput,
5 EditPredictionStartedDebugEvent, EditPredictionStore, ZedUpdateRequiredError,
6 cursor_excerpt::{editable_and_context_ranges_for_cursor_position, guess_token_count},
7 prediction::EditPredictionResult,
8};
9use anyhow::Result;
10use cloud_llm_client::{
11 PredictEditsBody, PredictEditsGitInfo, PredictEditsRequestTrigger, PredictEditsResponse,
12};
13use edit_prediction_types::PredictedCursorPosition;
14use gpui::{App, AppContext as _, AsyncApp, Context, Entity, SharedString, Task};
15use language::{
16 Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset, ToPoint as _, text_diff,
17};
18use project::{Project, ProjectPath};
19use release_channel::AppVersion;
20use text::Bias;
21use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification};
22use zeta_prompt::{
23 Event, ZetaPromptInput,
24 zeta1::{
25 CURSOR_MARKER, EDITABLE_REGION_END_MARKER, EDITABLE_REGION_START_MARKER,
26 START_OF_FILE_MARKER,
27 },
28};
29
30pub(crate) const MAX_CONTEXT_TOKENS: usize = 150;
31pub(crate) const MAX_REWRITE_TOKENS: usize = 350;
32pub(crate) const MAX_EVENT_TOKENS: usize = 500;
33
34pub(crate) fn request_prediction_with_zeta1(
35 store: &mut EditPredictionStore,
36 EditPredictionModelInput {
37 project,
38 buffer,
39 snapshot,
40 position,
41 events,
42 trigger,
43 debug_tx,
44 ..
45 }: EditPredictionModelInput,
46 cx: &mut Context<EditPredictionStore>,
47) -> Task<Result<Option<EditPredictionResult>>> {
48 let buffer_snapshotted_at = Instant::now();
49 let client = store.client.clone();
50 let llm_token = store.llm_token.clone();
51 let app_version = AppVersion::global(cx);
52
53 let (git_info, can_collect_file) = if let Some(file) = snapshot.file() {
54 let can_collect_file = store.can_collect_file(&project, file, cx);
55 let git_info = if can_collect_file {
56 git_info_for_file(&project, &ProjectPath::from_file(file.as_ref(), cx), cx)
57 } else {
58 None
59 };
60 (git_info, can_collect_file)
61 } else {
62 (None, false)
63 };
64
65 let full_path: Arc<Path> = snapshot
66 .file()
67 .map(|f| Arc::from(f.full_path(cx).as_path()))
68 .unwrap_or_else(|| Arc::from(Path::new("untitled")));
69 let full_path_str = full_path.to_string_lossy().into_owned();
70 let cursor_point = position.to_point(&snapshot);
71 let prompt_for_events = {
72 let events = events.clone();
73 move || prompt_for_events_impl(&events, MAX_EVENT_TOKENS)
74 };
75 let gather_task = gather_context(
76 full_path_str,
77 &snapshot,
78 cursor_point,
79 prompt_for_events,
80 trigger,
81 cx,
82 );
83
84 let (uri, require_auth) = match &store.custom_predict_edits_url {
85 Some(custom_url) => (custom_url.clone(), false),
86 None => {
87 match client
88 .http_client()
89 .build_zed_llm_url("/predict_edits/v2", &[])
90 {
91 Ok(url) => (url.into(), true),
92 Err(err) => return Task::ready(Err(err)),
93 }
94 }
95 };
96
97 cx.spawn(async move |this, cx| {
98 let GatherContextOutput {
99 mut body,
100 context_range,
101 editable_range,
102 included_events_count,
103 } = gather_task.await?;
104 let done_gathering_context_at = Instant::now();
105
106 let included_events = &events[events.len() - included_events_count..events.len()];
107 body.can_collect_data = can_collect_file
108 && this
109 .read_with(cx, |this, cx| this.can_collect_events(included_events, cx))
110 .unwrap_or(false);
111 if body.can_collect_data {
112 body.git_info = git_info;
113 }
114
115 log::debug!(
116 "Events:\n{}\nExcerpt:\n{:?}",
117 body.input_events,
118 body.input_excerpt
119 );
120
121 let response = EditPredictionStore::send_api_request::<PredictEditsResponse>(
122 |request| {
123 Ok(request
124 .uri(uri.as_str())
125 .body(serde_json::to_string(&body)?.into())?)
126 },
127 client,
128 llm_token,
129 app_version,
130 require_auth,
131 )
132 .await;
133
134 let context_start_offset = context_range.start.to_offset(&snapshot);
135 let context_start_row = context_range.start.row;
136 let editable_offset_range = editable_range.to_offset(&snapshot);
137
138 let inputs = ZetaPromptInput {
139 events: included_events.into(),
140 related_files: vec![],
141 cursor_path: full_path,
142 cursor_excerpt: snapshot
143 .text_for_range(context_range)
144 .collect::<String>()
145 .into(),
146 editable_range_in_excerpt: (editable_range.start - context_start_offset)
147 ..(editable_offset_range.end - context_start_offset),
148 cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - context_start_offset,
149 excerpt_start_row: Some(context_start_row),
150 };
151
152 if let Some(debug_tx) = &debug_tx {
153 debug_tx
154 .unbounded_send(DebugEvent::EditPredictionStarted(
155 EditPredictionStartedDebugEvent {
156 buffer: buffer.downgrade(),
157 prompt: Some(serde_json::to_string(&inputs).unwrap()),
158 position,
159 },
160 ))
161 .ok();
162 }
163
164 let (response, usage) = match response {
165 Ok(response) => response,
166 Err(err) => {
167 if err.is::<ZedUpdateRequiredError>() {
168 cx.update(|cx| {
169 this.update(cx, |ep_store, _cx| {
170 ep_store.update_required = true;
171 })
172 .ok();
173
174 let error_message: SharedString = err.to_string().into();
175 show_app_notification(
176 NotificationId::unique::<ZedUpdateRequiredError>(),
177 cx,
178 move |cx| {
179 cx.new(|cx| {
180 ErrorMessagePrompt::new(error_message.clone(), cx)
181 .with_link_button("Update Zed", "https://zed.dev/releases")
182 })
183 },
184 );
185 });
186 }
187
188 return Err(err);
189 }
190 };
191
192 let received_response_at = Instant::now();
193 log::debug!("completion response: {}", &response.output_excerpt);
194
195 if let Some(usage) = usage {
196 this.update(cx, |this, cx| {
197 this.user_store.update(cx, |user_store, cx| {
198 user_store.update_edit_prediction_usage(usage, cx);
199 });
200 })
201 .ok();
202 }
203
204 if let Some(debug_tx) = &debug_tx {
205 debug_tx
206 .unbounded_send(DebugEvent::EditPredictionFinished(
207 EditPredictionFinishedDebugEvent {
208 buffer: buffer.downgrade(),
209 model_output: Some(response.output_excerpt.clone()),
210 position,
211 },
212 ))
213 .ok();
214 }
215
216 let edit_prediction = process_completion_response(
217 response,
218 buffer,
219 &snapshot,
220 editable_range,
221 inputs,
222 buffer_snapshotted_at,
223 received_response_at,
224 cx,
225 )
226 .await;
227
228 let finished_at = Instant::now();
229
230 // record latency for ~1% of requests
231 if rand::random::<u8>() <= 2 {
232 telemetry::event!(
233 "Edit Prediction Request",
234 context_latency = done_gathering_context_at
235 .duration_since(buffer_snapshotted_at)
236 .as_millis(),
237 request_latency = received_response_at
238 .duration_since(done_gathering_context_at)
239 .as_millis(),
240 process_latency = finished_at.duration_since(received_response_at).as_millis()
241 );
242 }
243
244 edit_prediction.map(Some)
245 })
246}
247
248fn process_completion_response(
249 prediction_response: PredictEditsResponse,
250 buffer: Entity<Buffer>,
251 snapshot: &BufferSnapshot,
252 editable_range: Range<usize>,
253 inputs: ZetaPromptInput,
254 buffer_snapshotted_at: Instant,
255 received_response_at: Instant,
256 cx: &AsyncApp,
257) -> Task<Result<EditPredictionResult>> {
258 let snapshot = snapshot.clone();
259 let request_id = prediction_response.request_id;
260 let output_excerpt = prediction_response.output_excerpt;
261 cx.spawn(async move |cx| {
262 let output_excerpt: Arc<str> = output_excerpt.into();
263
264 let edits: Arc<[(Range<Anchor>, Arc<str>)]> = cx
265 .background_spawn({
266 let output_excerpt = output_excerpt.clone();
267 let editable_range = editable_range.clone();
268 let snapshot = snapshot.clone();
269 async move { parse_edits(output_excerpt.as_ref(), editable_range, &snapshot) }
270 })
271 .await?
272 .into();
273
274 let id = EditPredictionId(request_id.into());
275 Ok(EditPredictionResult::new(
276 id,
277 &buffer,
278 &snapshot,
279 edits,
280 None,
281 buffer_snapshotted_at,
282 received_response_at,
283 inputs,
284 cx,
285 )
286 .await)
287 })
288}
289
290pub(crate) fn parse_edits(
291 output_excerpt: &str,
292 editable_range: Range<usize>,
293 snapshot: &BufferSnapshot,
294) -> Result<Vec<(Range<Anchor>, Arc<str>)>> {
295 let content = output_excerpt.replace(CURSOR_MARKER, "");
296
297 let start_markers = content
298 .match_indices(EDITABLE_REGION_START_MARKER)
299 .collect::<Vec<_>>();
300 anyhow::ensure!(
301 start_markers.len() <= 1,
302 "expected at most one start marker, found {}",
303 start_markers.len()
304 );
305
306 let end_markers = content
307 .match_indices(EDITABLE_REGION_END_MARKER)
308 .collect::<Vec<_>>();
309 anyhow::ensure!(
310 end_markers.len() <= 1,
311 "expected at most one end marker, found {}",
312 end_markers.len()
313 );
314
315 let sof_markers = content
316 .match_indices(START_OF_FILE_MARKER)
317 .collect::<Vec<_>>();
318 anyhow::ensure!(
319 sof_markers.len() <= 1,
320 "expected at most one start-of-file marker, found {}",
321 sof_markers.len()
322 );
323
324 let content_start = start_markers
325 .first()
326 .map(|e| e.0 + EDITABLE_REGION_START_MARKER.len())
327 .map(|start| {
328 if content.len() > start
329 && content.is_char_boundary(start)
330 && content[start..].starts_with('\n')
331 {
332 start + 1
333 } else {
334 start
335 }
336 })
337 .unwrap_or(0);
338 let content_end = end_markers
339 .first()
340 .map(|e| {
341 if e.0 > 0 && content.is_char_boundary(e.0 - 1) && content[e.0 - 1..].starts_with('\n')
342 {
343 e.0 - 1
344 } else {
345 e.0
346 }
347 })
348 .unwrap_or(content.strip_suffix("\n").unwrap_or(&content).len());
349
350 // min to account for content_end and content_start both accounting for the same newline in the following case:
351 // <|editable_region_start|>\n<|editable_region_end|>
352 let new_text = &content[content_start.min(content_end)..content_end];
353
354 let old_text = snapshot
355 .text_for_range(editable_range.clone())
356 .collect::<String>();
357
358 Ok(compute_edits(
359 old_text,
360 new_text,
361 editable_range.start,
362 snapshot,
363 ))
364}
365
366pub fn compute_edits(
367 old_text: String,
368 new_text: &str,
369 offset: usize,
370 snapshot: &BufferSnapshot,
371) -> Vec<(Range<Anchor>, Arc<str>)> {
372 compute_edits_and_cursor_position(old_text, new_text, offset, None, snapshot).0
373}
374
375pub fn compute_edits_and_cursor_position(
376 old_text: String,
377 new_text: &str,
378 offset: usize,
379 cursor_offset_in_new_text: Option<usize>,
380 snapshot: &BufferSnapshot,
381) -> (
382 Vec<(Range<Anchor>, Arc<str>)>,
383 Option<PredictedCursorPosition>,
384) {
385 let diffs = text_diff(&old_text, new_text);
386
387 // Delta represents the cumulative change in byte count from all preceding edits.
388 // new_offset = old_offset + delta, so old_offset = new_offset - delta
389 let mut delta: isize = 0;
390 let mut cursor_position: Option<PredictedCursorPosition> = None;
391
392 let edits = diffs
393 .iter()
394 .map(|(raw_old_range, new_text)| {
395 // Compute cursor position if it falls within or before this edit.
396 if let (Some(cursor_offset), None) = (cursor_offset_in_new_text, cursor_position) {
397 let edit_start_in_new = (raw_old_range.start as isize + delta) as usize;
398 let edit_end_in_new = edit_start_in_new + new_text.len();
399
400 if cursor_offset < edit_start_in_new {
401 let cursor_in_old = (cursor_offset as isize - delta) as usize;
402 cursor_position = Some(PredictedCursorPosition::at_anchor(
403 snapshot.anchor_after(offset + cursor_in_old),
404 ));
405 } else if cursor_offset < edit_end_in_new {
406 let offset_within_insertion = cursor_offset - edit_start_in_new;
407 cursor_position = Some(PredictedCursorPosition::new(
408 snapshot.anchor_before(offset + raw_old_range.start),
409 offset_within_insertion,
410 ));
411 }
412
413 delta += new_text.len() as isize - raw_old_range.len() as isize;
414 }
415
416 // Compute the edit with prefix/suffix trimming.
417 let mut old_range = raw_old_range.clone();
418 let old_slice = &old_text[old_range.clone()];
419
420 let prefix_len = common_prefix(old_slice.chars(), new_text.chars());
421 let suffix_len = common_prefix(
422 old_slice[prefix_len..].chars().rev(),
423 new_text[prefix_len..].chars().rev(),
424 );
425
426 old_range.start += offset;
427 old_range.end += offset;
428 old_range.start += prefix_len;
429 old_range.end -= suffix_len;
430
431 let new_text = new_text[prefix_len..new_text.len() - suffix_len].into();
432 let range = if old_range.is_empty() {
433 let anchor = snapshot.anchor_after(old_range.start);
434 anchor..anchor
435 } else {
436 snapshot.anchor_after(old_range.start)..snapshot.anchor_before(old_range.end)
437 };
438 (range, new_text)
439 })
440 .collect();
441
442 if let (Some(cursor_offset), None) = (cursor_offset_in_new_text, cursor_position) {
443 let cursor_in_old = (cursor_offset as isize - delta) as usize;
444 let buffer_offset = snapshot.clip_offset(offset + cursor_in_old, Bias::Right);
445 cursor_position = Some(PredictedCursorPosition::at_anchor(
446 snapshot.anchor_after(buffer_offset),
447 ));
448 }
449
450 (edits, cursor_position)
451}
452
453fn common_prefix<T1: Iterator<Item = char>, T2: Iterator<Item = char>>(a: T1, b: T2) -> usize {
454 a.zip(b)
455 .take_while(|(a, b)| a == b)
456 .map(|(a, _)| a.len_utf8())
457 .sum()
458}
459
460fn git_info_for_file(
461 project: &Entity<Project>,
462 project_path: &ProjectPath,
463 cx: &App,
464) -> Option<PredictEditsGitInfo> {
465 let git_store = project.read(cx).git_store().read(cx);
466 if let Some((repository, _repo_path)) =
467 git_store.repository_and_path_for_project_path(project_path, cx)
468 {
469 let repository = repository.read(cx);
470 let head_sha = repository
471 .head_commit
472 .as_ref()
473 .map(|head_commit| head_commit.sha.to_string());
474 let remote_origin_url = repository.remote_origin_url.clone();
475 let remote_upstream_url = repository.remote_upstream_url.clone();
476 if head_sha.is_none() && remote_origin_url.is_none() && remote_upstream_url.is_none() {
477 return None;
478 }
479 Some(PredictEditsGitInfo {
480 head_sha,
481 remote_origin_url,
482 remote_upstream_url,
483 })
484 } else {
485 None
486 }
487}
488
489pub struct GatherContextOutput {
490 pub body: PredictEditsBody,
491 pub context_range: Range<Point>,
492 pub editable_range: Range<usize>,
493 pub included_events_count: usize,
494}
495
496pub fn gather_context(
497 full_path_str: String,
498 snapshot: &BufferSnapshot,
499 cursor_point: language::Point,
500 prompt_for_events: impl FnOnce() -> (String, usize) + Send + 'static,
501 trigger: PredictEditsRequestTrigger,
502 cx: &App,
503) -> Task<Result<GatherContextOutput>> {
504 cx.background_spawn({
505 let snapshot = snapshot.clone();
506 async move {
507 let input_excerpt = excerpt_for_cursor_position(
508 cursor_point,
509 &full_path_str,
510 &snapshot,
511 MAX_REWRITE_TOKENS,
512 MAX_CONTEXT_TOKENS,
513 );
514 let (input_events, included_events_count) = prompt_for_events();
515 let editable_range = input_excerpt.editable_range.to_offset(&snapshot);
516
517 let body = PredictEditsBody {
518 input_events,
519 input_excerpt: input_excerpt.prompt,
520 can_collect_data: false,
521 diagnostic_groups: None,
522 git_info: None,
523 outline: None,
524 speculated_output: None,
525 trigger,
526 };
527
528 Ok(GatherContextOutput {
529 body,
530 context_range: input_excerpt.context_range,
531 editable_range,
532 included_events_count,
533 })
534 }
535 })
536}
537
538pub(crate) fn prompt_for_events(events: &[Arc<Event>], max_tokens: usize) -> String {
539 prompt_for_events_impl(events, max_tokens).0
540}
541
542fn prompt_for_events_impl(events: &[Arc<Event>], mut remaining_tokens: usize) -> (String, usize) {
543 let mut result = String::new();
544 for (ix, event) in events.iter().rev().enumerate() {
545 let event_string = format_event(event.as_ref());
546 let event_tokens = guess_token_count(event_string.len());
547 if event_tokens > remaining_tokens {
548 return (result, ix);
549 }
550
551 if !result.is_empty() {
552 result.insert_str(0, "\n\n");
553 }
554 result.insert_str(0, &event_string);
555 remaining_tokens -= event_tokens;
556 }
557 return (result, events.len());
558}
559
560pub fn format_event(event: &Event) -> String {
561 match event {
562 Event::BufferChange {
563 path,
564 old_path,
565 diff,
566 ..
567 } => {
568 let mut prompt = String::new();
569
570 if old_path != path {
571 writeln!(
572 prompt,
573 "User renamed {} to {}\n",
574 old_path.display(),
575 path.display()
576 )
577 .unwrap();
578 }
579
580 if !diff.is_empty() {
581 write!(
582 prompt,
583 "User edited {}:\n```diff\n{}\n```",
584 path.display(),
585 diff
586 )
587 .unwrap();
588 }
589
590 prompt
591 }
592 }
593}
594
595#[derive(Debug)]
596pub struct InputExcerpt {
597 pub context_range: Range<Point>,
598 pub editable_range: Range<Point>,
599 pub prompt: String,
600}
601
602pub fn excerpt_for_cursor_position(
603 position: Point,
604 path: &str,
605 snapshot: &BufferSnapshot,
606 editable_region_token_limit: usize,
607 context_token_limit: usize,
608) -> InputExcerpt {
609 let (editable_range, context_range) = editable_and_context_ranges_for_cursor_position(
610 position,
611 snapshot,
612 editable_region_token_limit,
613 context_token_limit,
614 );
615
616 let mut prompt = String::new();
617
618 writeln!(&mut prompt, "```{path}").unwrap();
619 if context_range.start == Point::zero() {
620 writeln!(&mut prompt, "{START_OF_FILE_MARKER}").unwrap();
621 }
622
623 for chunk in snapshot.chunks(context_range.start..editable_range.start, false) {
624 prompt.push_str(chunk.text);
625 }
626
627 push_editable_range(position, snapshot, editable_range.clone(), &mut prompt);
628
629 for chunk in snapshot.chunks(editable_range.end..context_range.end, false) {
630 prompt.push_str(chunk.text);
631 }
632 write!(prompt, "\n```").unwrap();
633
634 InputExcerpt {
635 context_range,
636 editable_range,
637 prompt,
638 }
639}
640
641fn push_editable_range(
642 cursor_position: Point,
643 snapshot: &BufferSnapshot,
644 editable_range: Range<Point>,
645 prompt: &mut String,
646) {
647 writeln!(prompt, "{EDITABLE_REGION_START_MARKER}").unwrap();
648 for chunk in snapshot.chunks(editable_range.start..cursor_position, false) {
649 prompt.push_str(chunk.text);
650 }
651 prompt.push_str(CURSOR_MARKER);
652 for chunk in snapshot.chunks(cursor_position..editable_range.end, false) {
653 prompt.push_str(chunk.text);
654 }
655 write!(prompt, "\n{EDITABLE_REGION_END_MARKER}").unwrap();
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use gpui::{App, AppContext};
662 use indoc::indoc;
663 use language::Buffer;
664
665 #[gpui::test]
666 fn test_excerpt_for_cursor_position(cx: &mut App) {
667 let text = indoc! {r#"
668 fn foo() {
669 let x = 42;
670 println!("Hello, world!");
671 }
672
673 fn bar() {
674 let x = 42;
675 let mut sum = 0;
676 for i in 0..x {
677 sum += i;
678 }
679 println!("Sum: {}", sum);
680 return sum;
681 }
682
683 fn generate_random_numbers() -> Vec<i32> {
684 let mut rng = rand::thread_rng();
685 let mut numbers = Vec::new();
686 for _ in 0..5 {
687 numbers.push(rng.random_range(1..101));
688 }
689 numbers
690 }
691 "#};
692 let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language::rust_lang(), cx));
693 let snapshot = buffer.read(cx).snapshot();
694
695 // The excerpt expands to syntax boundaries.
696 // With 50 token editable limit, we get a region that expands to syntax nodes.
697 let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 50, 32);
698 assert_eq!(
699 excerpt.prompt,
700 indoc! {r#"
701 ```main.rs
702
703 fn bar() {
704 let x = 42;
705 <|editable_region_start|>
706 let mut sum = 0;
707 for i in 0..x {
708 sum += i;
709 }
710 println!("Sum: {}", sum);
711 r<|user_cursor_is_here|>eturn sum;
712 }
713
714 fn generate_random_numbers() -> Vec<i32> {
715 <|editable_region_end|>
716 let mut rng = rand::thread_rng();
717 let mut numbers = Vec::new();
718 ```"#}
719 );
720
721 // With smaller budget, the region expands to syntax boundaries but is tighter.
722 let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 40, 32);
723 assert_eq!(
724 excerpt.prompt,
725 indoc! {r#"
726 ```main.rs
727 fn bar() {
728 let x = 42;
729 let mut sum = 0;
730 for i in 0..x {
731 <|editable_region_start|>
732 sum += i;
733 }
734 println!("Sum: {}", sum);
735 r<|user_cursor_is_here|>eturn sum;
736 }
737
738 fn generate_random_numbers() -> Vec<i32> {
739 <|editable_region_end|>
740 let mut rng = rand::thread_rng();
741 ```"#}
742 );
743 }
744
745 #[gpui::test]
746 fn test_parse_edits_empty_editable_region(cx: &mut App) {
747 let text = "fn foo() {\n let x = 42;\n}\n";
748 let buffer = cx.new(|cx| Buffer::local(text, cx));
749 let snapshot = buffer.read(cx).snapshot();
750
751 let output = "<|editable_region_start|>\n<|editable_region_end|>";
752 let editable_range = 0..text.len();
753 let edits = parse_edits(output, editable_range, &snapshot).unwrap();
754 assert_eq!(edits.len(), 1);
755 let (range, new_text) = &edits[0];
756 assert_eq!(range.to_offset(&snapshot), 0..text.len(),);
757 assert_eq!(new_text.as_ref(), "");
758 }
759
760 #[gpui::test]
761 fn test_parse_edits_multibyte_char_before_end_marker(cx: &mut App) {
762 let text = "// café";
763 let buffer = cx.new(|cx| Buffer::local(text, cx));
764 let snapshot = buffer.read(cx).snapshot();
765
766 let output = "<|editable_region_start|>\n// café<|editable_region_end|>";
767 let editable_range = 0..text.len();
768
769 let edits = parse_edits(output, editable_range, &snapshot).unwrap();
770 assert_eq!(edits, vec![]);
771 }
772
773 #[gpui::test]
774 fn test_parse_edits_multibyte_char_after_start_marker(cx: &mut App) {
775 let text = "é is great";
776 let buffer = cx.new(|cx| Buffer::local(text, cx));
777 let snapshot = buffer.read(cx).snapshot();
778
779 let output = "<|editable_region_start|>é is great\n<|editable_region_end|>";
780 let editable_range = 0..text.len();
781
782 let edits = parse_edits(output, editable_range, &snapshot).unwrap();
783 assert!(edits.is_empty());
784 }
785}