Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/agent/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,14 @@ pub struct AgentRunner {
/// emits `AgentEvent::Interjected` with whatever assistant text had
/// streamed so far, and the UI is responsible for queueing the next
/// user turn. Unbounded because the signal payload is just `()`.
pub interject_tx: mpsc::UnboundedSender<()>,
/// F20: bounded so a user who hammers the interject keybind
/// can't fill an unbounded queue while the runner is in a long
/// LLM call. Only the FIRST signal needs to be received — all
/// subsequent ones are noise (the runner drains via
/// `try_recv()` after the first wakeup). 64 is generous; if
/// the channel is full, `try_send` silently no-ops (we already
/// have one queued).
pub interject_tx: mpsc::Sender<()>,
}

pub fn convert_history(session: &Session) -> Vec<Message> {
Expand Down Expand Up @@ -220,7 +227,7 @@ async fn run_stream<M, P>(
prompt: &str,
history: Vec<Message>,
event_tx: &mpsc::Sender<AgentEvent>,
interject_rx: &mut mpsc::UnboundedReceiver<()>,
interject_rx: &mut mpsc::Receiver<()>,
) -> StreamOutcome
where
M: CompletionModel + 'static,
Expand Down Expand Up @@ -361,7 +368,8 @@ where
{
cache.clear();
let (event_tx, event_rx) = mpsc::channel::<AgentEvent>(256);
let (interject_tx, mut interject_rx) = mpsc::unbounded_channel::<()>();
// F20: bounded channel. See `AgentRunner::interject_tx` doc.
let (interject_tx, mut interject_rx) = mpsc::channel::<()>(64);

let task = tokio::spawn(async move {
let policy = RecoveryPolicy::default();
Expand Down
69 changes: 69 additions & 0 deletions src/agent/tools/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,21 @@ impl Tool for ReadTool {
let mut total_lines = 0usize;
let mut excerpt_lines: Vec<(usize, String)> = Vec::with_capacity(limit);
let want_end = offset.saturating_add(limit);
let mut first_line = true;
while let Some(line) = lines.next_line().await.transpose() {
let mut line = line?;
// F19: strip UTF-8 BOM from the FIRST line only. Old
// Windows-saved files start with U+FEFF (0xEF 0xBB 0xBF);
// when present, the BOM ended up as a leading 3-byte
// invisible-character prefix in the LLM context.
// opencode `read.ts` uses `Bom.readFile()` for the same
// reason.
if first_line {
if let Some(stripped) = line.strip_prefix('\u{FEFF}') {
line = stripped.to_string();
}
first_line = false;
}
if line.len() > MAX_LINE_BYTES {
// Truncate by byte index — careful to land on a UTF-8
// boundary. Drop bytes until we find one.
Expand Down Expand Up @@ -276,4 +289,60 @@ mod tests {
let body_lines: Vec<&str> = out.lines().skip(2).collect();
assert_eq!(body_lines.len(), 5);
}

/// F19: UTF-8 BOM (U+FEFF, bytes 0xEF 0xBB 0xBF) at the start
/// of a file is stripped before the line reaches the LLM. The
/// raw 3-byte prefix would otherwise render as an
/// invisible-character at the start of line 1.
#[tokio::test]
async fn read_strips_utf8_bom_from_first_line() {
let path = temp_path("bom");
let bom = "\u{FEFF}";
std::fs::write(&path, format!("{bom}first\nsecond")).unwrap();

let tool = ReadTool::new(None, None);
let out = tool
.call(ReadArgs {
path: path.to_string_lossy().into_owned(),
offset: None,
limit: None,
})
.await
.unwrap();
let _ = std::fs::remove_file(&path);

// Body lines (after the "File: …" header + blank line).
let body: Vec<&str> = out.lines().skip(2).collect();
assert_eq!(body, vec!["1: first", "2: second"]);
// No BOM byte anywhere in the output.
assert!(
!out.contains('\u{FEFF}'),
"BOM should be stripped: {:?}",
out,
);
}

/// F19: only the FIRST line gets BOM-stripped. A mid-file BOM
/// (extremely rare but possible) is preserved as a regular
/// character.
#[tokio::test]
async fn read_only_strips_bom_at_start_of_file() {
let path = temp_path("bom-mid");
let bom = "\u{FEFF}";
std::fs::write(&path, format!("first\n{bom}second")).unwrap();

let tool = ReadTool::new(None, None);
let out = tool
.call(ReadArgs {
path: path.to_string_lossy().into_owned(),
offset: None,
limit: None,
})
.await
.unwrap();
let _ = std::fs::remove_file(&path);

// The mid-file BOM stays.
assert!(out.contains('\u{FEFF}'));
}
}
12 changes: 10 additions & 2 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,10 @@ pub async fn run_interactive(
// Sender into the running agent's interjection channel. The UI signals
// (unit-only payload) when a user-typed interjection is queued; the
// runner honors it at the next tool-result boundary.
let mut agent_interject: Option<mpsc::UnboundedSender<()>> = None;
// F20: bounded mpsc::Sender. Multiple interject signals while
// the runner is mid-call get coalesced — only the first wakeup
// matters since the runner drains via try_recv() after waking.
let mut agent_interject: Option<mpsc::Sender<()>> = None;
let mut agent_line_started = false;
let mut response_buf = String::new();
// Count of `AgentEvent::ToolCall` events observed during the
Expand Down Expand Up @@ -1385,7 +1388,12 @@ pub async fn run_interactive(
// (race with Done) — harmless, queue still
// drains on the Done handler.
if let Some(tx) = agent_interject.as_ref() {
let _ = tx.send(());
// F20: try_send so a full channel
// (already-queued wakeup) is a
// no-op rather than blocking the
// UI thread. We only need ONE
// wakeup queued at a time.
let _ = tx.try_send(());
}
for line in text.lines() {
let safe_line = sanitize_output(line);
Expand Down
Loading