diff --git a/crates/pi-natives/src/chunk/edit.rs b/crates/pi-natives/src/chunk/edit.rs index 08ce4724e..d659d2d16 100644 --- a/crates/pi-natives/src/chunk/edit.rs +++ b/crates/pi-natives/src/chunk/edit.rs @@ -2748,8 +2748,12 @@ mod tests { .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, + "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 { @@ -2757,8 +2761,11 @@ mod tests { 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()), + content: Some( + " /// Initializes and starts the server. + pub fn start(&mut self) {" + .to_owned(), + ), find: None, }); @@ -2770,14 +2777,18 @@ mod tests { body_count, result.diff_after ); assert!( - result.diff_after.contains("/// Initializes and starts the server."), + result + .diff_after + .contains("/// Initializes and starts the server."), "new doc comment should be in output: -{}", result.diff_after +{}", + result.diff_after ); assert!( !result.diff_after.contains("/// Starts the server."), "old doc comment should be removed: -{}", result.diff_after +{}", + result.diff_after ); } @@ -2804,18 +2815,18 @@ mod tests { .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" - ); + 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()), + content: Some( + " /** Initializes the server. */ + start() {" + .to_owned(), + ), find: None, }); @@ -2829,8 +2840,152 @@ mod tests { assert!( result.diff_after.contains("/** Initializes the server. */"), "new doc comment should be in output: -{}", result.diff_after +{}", + result.diff_after ); } + #[test] + fn python_body_replace_does_not_corrupt_surrounding_code() { + let source = + "import os\n\ndef main():\n x = 1\n print(x)\n\ndef helper():\n return 42\n"; + let state = state_for(source, "python"); + let chunk = state.inner().chunk("fn_main").expect("fn_main"); + + let result = apply_single_edit(&state, "test.py", EditOperation { + op: ChunkEditOp::Replace, + sel: Some(format!("fn_main#{}@body", chunk.checksum)), + crc: None, + region: None, + content: Some("y = 2\nprint(y)\n".to_owned()), + find: None, + }); + + assert!( + result.diff_after.contains("import os"), + "imports should survive body replace: {}", + result.diff_after + ); + assert!( + result.diff_after.contains("def main"), + "function head should survive body replace: {}", + result.diff_after + ); + assert!( + result.diff_after.contains("y = 2"), + "replacement body should appear: {}", + result.diff_after + ); + assert!( + result.diff_after.contains("def helper"), + "sibling function should survive body replace: {}", + result.diff_after + ); + assert!( + result.diff_after.contains("return 42"), + "sibling function body should survive: {}", + result.diff_after + ); + // Imports should remain at column 0, not indented + assert!( + result.diff_after.starts_with("import os"), + "import should be at column 0: {:?}", + &result.diff_after[..40.min(result.diff_after.len())] + ); + } + + #[test] + fn python_head_replace_does_not_orphan_body() { + let source = "class Server:\n def start(self) -> None:\n self.running = True\n"; + let state = state_for(source, "python"); + let chunk = state + .inner() + .chunk("class_Server.fn_start") + .expect("fn_start"); + + let result = apply_single_edit(&state, "test.py", EditOperation { + op: ChunkEditOp::Replace, + sel: Some(format!("class_Server.fn_start#{}@head", chunk.checksum)), + crc: None, + region: None, + content: Some("def begin(self) -> None:\n".to_owned()), + find: None, + }); + + assert!( + result.diff_after.contains("def begin"), + "replaced head should appear: {}", + result.diff_after + ); + assert!( + result.diff_after.contains("self.running = True"), + "body should survive head replace: {}", + result.diff_after + ); + } + + #[test] + fn python_body_prepend_has_correct_indentation() { + let source = "def main():\n x = 1\n print(x)\n"; + let state = state_for(source, "python"); + + let result = apply_single_edit(&state, "test.py", EditOperation { + op: ChunkEditOp::Prepend, + sel: Some("fn_main@body".to_owned()), + crc: None, + region: None, + content: Some("y = 0\n".to_owned()), + find: None, + }); + + assert!( + result.diff_after.contains("y = 0"), + "prepended content should appear: {}", + result.diff_after + ); + assert!( + result.diff_after.contains("x = 1"), + "existing body should survive: {}", + result.diff_after + ); + // The prepended content should be at the body indent level + assert!( + result.diff_after.contains(" y = 0"), + "prepended content should be at body indent: {}", + result.diff_after + ); + } + + #[test] + fn python_class_body_replace_preserves_structure() { + let source = + "class Server:\n def start(self):\n pass\n\n def stop(self):\n pass\n"; + let state = state_for(source, "python"); + let chunk = state.inner().chunk("class_Server").expect("class_Server"); + + let result = apply_single_edit(&state, "test.py", EditOperation { + op: ChunkEditOp::Replace, + sel: Some(format!("class_Server#{}@body", chunk.checksum)), + crc: None, + region: None, + content: Some("def run(self):\n\tpass\n".to_owned()), + find: None, + }); + + assert!( + result.diff_after.contains("class Server:"), + "class header should survive body replace: {}", + result.diff_after + ); + assert!( + result.diff_after.contains("def run(self)"), + "replaced body should appear: {}", + result.diff_after + ); + assert!( + !result.diff_after.contains("def start"), + "old body should be replaced: {}", + result.diff_after + ); + } } diff --git a/crates/pi-natives/src/chunk/mod.rs b/crates/pi-natives/src/chunk/mod.rs index e03114b6d..575e85d08 100644 --- a/crates/pi-natives/src/chunk/mod.rs +++ b/crates/pi-natives/src/chunk/mod.rs @@ -58,7 +58,6 @@ mod ast_vue; use std::collections::HashMap; - use ast_grep_core::tree_sitter::LanguageExt; use napi::{Error, Result}; use napi_derive::napi; @@ -326,8 +325,27 @@ fn build_chunk( .unwrap_or_default(), ); let recurse = candidate.recurse; + let chunk_start = candidate.range_start_byte; + let mut chunk_end = candidate.range_end_byte; let region_boundaries = recurse.map(|recurse| { - compute_body_inner_boundaries(source, recurse.node.start_byte(), recurse.node.end_byte()) + let (pro_end, epi_start) = + compute_body_inner_boundaries(source, recurse.node.start_byte(), recurse.node.end_byte()); + // For indent-based languages (Python, Ruby, etc.) the body boundary + // computation may extend past the tree-sitter node to include a + // trailing newline that logically terminates the last body line. + // When this happens and the source byte at chunk_end is indeed a + // newline, extend the chunk's end_byte to match so that: + // - @body covers complete lines including their trailing newline + // - @tail remains a valid (empty) region + if epi_start > chunk_end + && epi_start <= source.len() + && source.as_bytes().get(chunk_end) == Some(&b'\n') + { + chunk_end = epi_start; + } + let pro_end = pro_end.max(chunk_start).min(chunk_end); + let epi_start = epi_start.max(pro_end).min(chunk_end); + (pro_end, epi_start) }); let child_candidates = recurse .map(|recurse| { @@ -371,7 +389,7 @@ fn build_chunk( end_line: candidate.range_end_line as u32, line_count: line_count as u32, start_byte: candidate.range_start_byte as u32, - end_byte: candidate.range_end_byte as u32, + end_byte: chunk_end as u32, checksum_start_byte: candidate.checksum_start_byte as u32, prologue_end_byte: region_boundaries.map(|(start, _)| start as u32), epilogue_start_byte: region_boundaries.map(|(_, end)| end as u32), @@ -2259,11 +2277,190 @@ end } #[test] - + fn python_region_boundaries_correct_for_function() { + use super::{resolve::chunk_region_range, types::ChunkRegion}; + + let source = "def run():\n return 1\n"; + let tree = build_chunk_tree(source, "python").expect("tree should build"); + let fn_chunk = tree + .chunks + .iter() + .find(|c| c.path == "fn_run") + .expect("fn_run"); + + let (head_s, head_e) = chunk_region_range(fn_chunk, ChunkRegion::Head); + let head = &source[head_s..head_e]; + assert!(head.contains("def run():"), "fn @head should contain def signature, got {head:?}"); + assert!(!head.contains("return"), "fn @head should not contain body, got {head:?}"); + + let (body_s, body_e) = chunk_region_range(fn_chunk, ChunkRegion::Body); + let body = &source[body_s..body_e]; + assert!(body.contains("return 1"), "fn @body should contain body, got {body:?}"); + assert!(!body.contains("def run"), "fn @body should not contain head, got {body:?}"); + assert!(body.ends_with('\n'), "fn @body should end with newline, got {body:?}"); + + let (tail_s, tail_e) = chunk_region_range(fn_chunk, ChunkRegion::Tail); + assert!(tail_e >= tail_s, "tail range must not be inverted"); + assert_eq!(tail_e - tail_s, 0, "fn @tail should be empty for Python"); + } #[test] - + fn python_region_boundaries_correct_for_class() { + use super::{resolve::chunk_region_range, types::ChunkRegion}; + + let source = + "class Server:\n def start(self) -> None:\n self.running = True\n \ + print('ok')\n\n def stop(self):\n pass\n"; + let tree = build_chunk_tree(source, "python").expect("tree should build"); + let class_chunk = tree + .chunks + .iter() + .find(|c| c.path == "class_Server") + .expect("class_Server"); + + let (head_s, head_e) = chunk_region_range(class_chunk, ChunkRegion::Head); + let head = &source[head_s..head_e]; + assert!(head.contains("class Server:"), "class @head should contain class def, got {head:?}"); + assert!(!head.contains("def start"), "class @head should not contain methods, got {head:?}"); + + let (body_s, body_e) = chunk_region_range(class_chunk, ChunkRegion::Body); + let body = &source[body_s..body_e]; + assert!(body.contains("def start"), "class @body should contain methods, got {body:?}"); + assert!(body.contains("def stop"), "class @body should contain all methods, got {body:?}"); + assert!( + !body.contains("class Server"), + "class @body should not contain header, got {body:?}" + ); + + let (tail_s, tail_e) = chunk_region_range(class_chunk, ChunkRegion::Tail); + assert!(tail_e >= tail_s, "tail range must not be inverted"); + assert_eq!(tail_e - tail_s, 0, "class @tail should be empty for Python"); + } #[test] - + fn python_decorated_function_region_boundaries() { + use super::{resolve::chunk_region_range, types::ChunkRegion}; + + let source = + "class Server:\n @property\n def address(self) -> str:\n return self._addr\n"; + let tree = build_chunk_tree(source, "python").expect("tree should build"); + let fn_chunk = tree + .chunks + .iter() + .find(|c| c.path == "class_Server.fn_address") + .expect("fn_address"); + + let (head_s, head_e) = chunk_region_range(fn_chunk, ChunkRegion::Head); + let head = &source[head_s..head_e]; + assert!(head.contains("@property"), "@head should include decorator, got {head:?}"); + assert!(head.contains("def address"), "@head should include def, got {head:?}"); + assert!(!head.contains("return"), "@head should not include body, got {head:?}"); + + let (body_s, body_e) = chunk_region_range(fn_chunk, ChunkRegion::Body); + let body = &source[body_s..body_e]; + assert!(body.contains("return self._addr"), "@body should contain return, got {body:?}"); + assert!(!body.contains("@property"), "@body should not contain decorator, got {body:?}"); + assert!(!body.contains("def address"), "@body should not contain def, got {body:?}"); + + let (tail_s, tail_e) = chunk_region_range(fn_chunk, ChunkRegion::Tail); + assert!(tail_e >= tail_s, "tail range must not be inverted"); + assert_eq!(tail_e - tail_s, 0, "fn @tail should be empty for Python"); + } + + #[test] + fn python_body_replace_preserves_surrounding_code() { + use super::{resolve::chunk_region_range, types::ChunkRegion}; + + let source = + "import os\n\ndef main():\n x = 1\n print(x)\n\ndef helper():\n return 42\n"; + let tree = build_chunk_tree(source, "python").expect("tree should build"); + let fn_main = tree + .chunks + .iter() + .find(|c| c.path == "fn_main") + .expect("fn_main"); + + let (body_s, body_e) = chunk_region_range(fn_main, ChunkRegion::Body); + let body = &source[body_s..body_e]; + assert!(!body.contains("def main"), "body should not include def"); + assert!(body.contains("x = 1"), "body should contain body lines"); + assert!(!body.contains("def helper"), "body should not leak into next function"); + assert!(!body.contains("return 42"), "body should not include helper's code"); + assert!(!body.contains("import"), "body should not include imports"); + + // Simulate body replace and verify the result + let replacement = " y = 2\n print(y)\n"; + let mut result = String::new(); + result.push_str(&source[..body_s]); + result.push_str(replacement); + result.push_str(&source[body_e..]); + assert!( + result.starts_with("import os"), + "import should remain at column 0 after body replace: {:?}", + &result[..40.min(result.len())] + ); + assert!(result.contains("def helper"), "helper fn should survive body replace: {result:?}"); + assert!(result.contains("return 42"), "helper's body should survive: {result:?}"); + } + + #[test] + fn python_class_body_replace_does_not_corrupt() { + use super::{resolve::chunk_region_range, types::ChunkRegion}; + + let source = + "class Server:\n def __init__(self, host: str, port: int):\n self.host = \ + host\n self.port = port\n\n def start(self) -> None:\n if \ + self.running:\n raise RuntimeError(\"already running\")\n \ + self.running = True\n print(f\"Started on {self.host}:{self.port}\")\n\n \ + @property\n def address(self) -> str:\n return f\"{self.host}:{self.port}\"\n"; + let tree = build_chunk_tree(source, "python").expect("tree should build"); + + // Verify class regions + let cls = tree + .chunks + .iter() + .find(|c| c.path == "class_Server") + .expect("class_Server"); + let (body_s, body_e) = chunk_region_range(cls, ChunkRegion::Body); + let body = &source[body_s..body_e]; + assert!(body.contains("def __init__"), "class body should contain __init__"); + assert!(body.contains("def start"), "class body should contain start"); + assert!(body.contains("@property"), "class body should contain @property"); + assert!(body.contains("def address"), "class body should contain address"); + assert!(!body.contains("class Server"), "class body should not contain header"); + + // Verify fn_start regions + let fn_start = tree + .chunks + .iter() + .find(|c| c.path == "class_Server.fn_start") + .expect("fn_start"); + let (fn_body_s, fn_body_e) = chunk_region_range(fn_start, ChunkRegion::Body); + let fn_body = &source[fn_body_s..fn_body_e]; + assert!( + fn_body.contains("if self.running"), + "fn_start @body should contain body, got {fn_body:?}" + ); + assert!( + fn_body.contains("self.running = True"), + "fn_start @body should contain all lines, got {fn_body:?}" + ); + assert!( + !fn_body.contains("def start"), + "fn_start @body should not include head, got {fn_body:?}" + ); + assert!( + !fn_body.contains("@property"), + "fn_start @body should not leak into next method, got {fn_body:?}" + ); + + // Verify all tail regions are empty + for chunk in &tree.chunks { + if chunk.path.is_empty() { + continue; + } + let (ts, te) = chunk_region_range(chunk, ChunkRegion::Tail); + assert!(te >= ts, "tail of {:?} must not be inverted: start={ts} end={te}", chunk.path); + } + } } diff --git a/crates/pi-natives/src/chunk/resolve.rs b/crates/pi-natives/src/chunk/resolve.rs index bbac388de..c0626e2f5 100644 --- a/crates/pi-natives/src/chunk/resolve.rs +++ b/crates/pi-natives/src/chunk/resolve.rs @@ -194,8 +194,12 @@ fn root_chunk(state: &ChunkStateInner) -> Result<&ChunkNode, String> { pub fn chunk_region_range(chunk: &ChunkNode, region: ChunkRegion) -> (usize, usize) { let start = chunk.start_byte as usize; let end = chunk.end_byte as usize; - let pro_end = chunk.prologue_end_byte.map_or(start, |b| b as usize); - let epi_start = chunk.epilogue_start_byte.map_or(end, |b| b as usize); + let pro_end = chunk + .prologue_end_byte + .map_or(start, |b| (b as usize).clamp(start, end)); + let epi_start = chunk + .epilogue_start_byte + .map_or(end, |b| (b as usize).clamp(pro_end, end)); match region { ChunkRegion::Head => (start, pro_end), ChunkRegion::Body => (pro_end, epi_start), diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 92a0d38fa..de7c11a62 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Simplified chunk edit operations: removed `append_child`, `prepend_child`, `append_sibling`, `prepend_sibling`, and `replace_body` ops in favor of unified `replace`, `before`, `after`, `prepend`, and `append` with region targeting (`@head`, `@body`, `@tail`) @@ -72,6 +73,8 @@ ### Changed +- Extracted working directory formatting logic into `formatToolWorkingDirectory()` utility for consistent path display across tools +- Bash command rendering now sanitizes tabs and shortens home directory paths in command previews - 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 diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index dcde941f0..93ec803b6 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1,5 +1,4 @@ import * as fs from "node:fs"; -import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui"; @@ -21,7 +20,7 @@ import { applyHeadTail } from "./bash-normalize"; import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls"; import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta"; import { resolveToCwd } from "./path-utils"; -import { replaceTabs } from "./render-utils"; +import { formatToolWorkingDirectory, replaceTabs } from "./render-utils"; import { ToolAbortError, ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; import { clampTimeout } from "./tool-timeouts"; @@ -480,26 +479,10 @@ interface BashRenderContext { } function formatBashCommand(args: BashRenderArgs): string { - const command = args.command || "…"; + const command = replaceTabs(args.command || "…"); const prompt = "$"; const cwd = getProjectDir(); - let displayWorkdir = args.cwd; - - if (displayWorkdir) { - const resolvedCwd = path.resolve(cwd); - const resolvedWorkdir = path.resolve(displayWorkdir); - if (resolvedWorkdir === resolvedCwd) { - displayWorkdir = undefined; - } else { - const relativePath = path.relative(resolvedCwd, resolvedWorkdir); - const isWithinCwd = - relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${path.sep}`); - if (isWithinCwd) { - displayWorkdir = relativePath; - } - } - } - + const displayWorkdir = formatToolWorkingDirectory(args.cwd, cwd); const renderedCommand = [formatBashEnvAssignments(getBashEnvForDisplay(args)), command].filter(Boolean).join(" "); return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${renderedCommand}` : `${prompt} ${renderedCommand}`; } diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index 8e3a59ac6..6fae05e7b 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -5,8 +5,9 @@ * tool renderers to ensure a unified TUI experience. */ import * as os from "node:os"; +import * as path from "node:path"; import type { Ellipsis } from "@oh-my-pi/pi-natives"; -import { truncateToWidth } from "@oh-my-pi/pi-tui"; +import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui"; import { pluralize } from "@oh-my-pi/pi-utils"; import { settings } from "../config/settings"; import type { Theme } from "../modes/theme/theme"; @@ -547,6 +548,20 @@ export function shortenPath(filePath: string, homeDir?: string): string { return filePath; } +export function formatToolWorkingDirectory(workdir: string | undefined, projectDir: string): string | undefined { + if (!workdir) return undefined; + const resolvedProjectDir = path.resolve(projectDir); + const resolvedWorkdir = path.resolve(projectDir, workdir); + if (resolvedWorkdir === resolvedProjectDir) { + return undefined; + } + const relativePath = path.relative(resolvedProjectDir, resolvedWorkdir); + const isWithinProject = + relativePath.length > 0 && !relativePath.startsWith("..") && !relativePath.startsWith(`..${path.sep}`); + const displayWorkdir = isWithinProject ? relativePath : shortenPath(resolvedWorkdir); + return replaceTabs(displayWorkdir); +} + export function formatScreenshot(opts: { saveFullRes: boolean; savedMimeType: string; diff --git a/packages/coding-agent/test/tools/bash-sixel-render.test.ts b/packages/coding-agent/test/tools/bash-sixel-render.test.ts index 7e89a6ac7..f801d2d55 100644 --- a/packages/coding-agent/test/tools/bash-sixel-render.test.ts +++ b/packages/coding-agent/test/tools/bash-sixel-render.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, it } from "bun:test"; +import * as os from "node:os"; +import * as path from "node:path"; import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core"; import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { bashToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/bash"; @@ -49,6 +51,24 @@ describe("bashToolRenderer", () => { expect(rendered).toContain("printf '%s' \"$MERMAID\""); }); + it("sanitizes command tabs and shortens home cwd in previews", async () => { + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + const uiTheme = theme!; + const component = bashToolRenderer.renderCall( + { + command: "printf\t'%s'", + cwd: path.join(os.homedir(), "projects", "demo"), + }, + { expanded: false, isPartial: false }, + uiTheme, + ); + const rendered = sanitizeText(component.render(120).join("\n")); + expect(rendered).toContain("~/projects/demo"); + expect(rendered).not.toContain(os.homedir()); + expect(rendered).not.toContain("\t"); + }); + it("bypasses truncation/styling for SIXEL lines", async () => { terminal.imageProtocol = ImageProtocol.Sixel; const theme = await getThemeByName("dark"); diff --git a/scripts/rate-edit-tool.py b/scripts/rate-edit-tool.py index dd97693af..e7516d0ae 100755 --- a/scripts/rate-edit-tool.py +++ b/scripts/rate-edit-tool.py @@ -1368,6 +1368,12 @@ def run_model_sync( time.sleep(min(max(recorder.auto_retry_delay_ms / 1000.0, 0.2), 2.0)) continue if recorder.agent_ended: + grace = min(0.5, max(deadline - time.monotonic(), 0.0)) + if grace > 0: + time.sleep(grace) + if recorder.auto_retry_active: + time.sleep(min(max(recorder.auto_retry_delay_ms / 1000.0, 0.2), 2.0)) + continue return if recorder.is_effectively_complete(quiet_seconds=2.0): return