Agent setup and streaming fixes: Aider, Antigravity on Windows, chat-only models, SSE limits and retries (#653)

Replays #616, #617, #619, #620 and #621. Aider isn't offered until it has an adapter, Antigravity is found under a redirected LOCALAPPDATA, servers with only specialised models aren't set up for chat, an SSE event's data is capped, and a retry keeps the original request when the agent's session can't be restored. Program lookup adds lower-case extensions on Windows.

Co-authored-by: Afloat <132809511+Afloat16@users.noreply.github.com>
This commit is contained in:
BFlat
2026-11-03 20:41:06 +07:00
committed by GitHub
parent 99bfba88bf
commit aeb86a69a3
8 changed files with 369 additions and 68 deletions
+125 -12
View File
@@ -110,6 +110,11 @@ enum Pending {
Prompt,
}
struct QueuedPrompt {
text: String,
continuation: Option<String>,
}
/// The ACP protocol for one conversation.
pub struct AcpCore {
backend: String,
@@ -121,7 +126,9 @@ pub struct AcpCore {
pending: HashMap<u64, Pending>,
session: Option<String>,
ready: bool,
queued: VecDeque<String>,
queued: VecDeque<QueuedPrompt>,
/// Only a loaded session or one already sent a prompt can continue an interrupted turn.
session_has_history: bool,
/// Permission requests waiting for an answer: our call id → (JSON-RPC id, options).
asks: HashMap<String, (Value, Vec<Value>)>,
/// Tool call titles by id, for the finished event.
@@ -144,6 +151,7 @@ impl AcpCore {
session: None,
ready: false,
queued: VecDeque::new(),
session_has_history: false,
asks: HashMap::new(),
tools: HashMap::new(),
decides: false,
@@ -181,8 +189,12 @@ impl AcpCore {
/// A message from the person: sent now, or once the session is ready.
pub fn prompt(&mut self, text: &str) -> Vec<Out> {
self.queue_prompt(text, None)
}
fn queue_prompt(&mut self, text: &str, continuation: Option<String>) -> Vec<Out> {
self.failure = None;
self.queued.push_back(text.to_string());
self.queued.push_back(QueuedPrompt { text: text.to_string(), continuation });
// A start that failed earlier (an `initialize` error) left nothing under way: start again,
// or the message would wait for a session that never comes (#271).
if !self.ready && self.pending.is_empty() {
@@ -194,7 +206,9 @@ impl AcpCore {
fn flush(&mut self) -> Vec<Out> {
let busy = self.pending.values().any(|p| *p == Pending::Prompt);
let Some(sid) = self.session.clone().filter(|_| self.ready && !busy) else { return Vec::new() };
let Some(text) = self.queued.pop_front() else { return Vec::new() };
let Some(prompt) = self.queued.pop_front() else { return Vec::new() };
let text = if self.session_has_history { prompt.continuation.unwrap_or(prompt.text) } else { prompt.text };
self.session_has_history = true;
let params = json!({"sessionId": sid, "prompt": [{"type": "text", "text": text}]});
vec![self.request("session/prompt", params, Pending::Prompt)]
}
@@ -234,6 +248,7 @@ impl AcpCore {
// A session that can't be continued starts afresh rather than failing the message.
if kind == Pending::LoadSession {
self.resume = None;
self.session = None;
let params = json!({"cwd": self.cwd, "mcpServers": self.mcp_servers()});
return vec![self.request("session/new", params, Pending::NewSession)];
}
@@ -270,6 +285,7 @@ impl AcpCore {
return self.fail(format!("{} started no session", self.agent_name), None);
}
self.ready = true;
self.session_has_history = kind == Pending::LoadSession;
self.flush()
}
Pending::Prompt => {
@@ -573,14 +589,7 @@ impl AcpConversation {
s.apply(outs, &self.shared, &self.tx, &self.busy);
}
}
}
impl Conversation for AcpConversation {
fn last_failure(&self) -> Option<TurnFailure> {
self.failure.or_else(|| self.lock().as_ref().and_then(|s| s.core.failure))
}
fn send(&mut self, text: &str) {
fn send_prompt(&mut self, text: &str, retry: bool) {
if self.busy.swap(true, Ordering::SeqCst) {
let _ = self.tx.send(AgentEvent::Error {
message: "Still working on the last message.".into(),
@@ -608,7 +617,22 @@ impl Conversation for AcpConversation {
}
}
let text = format!("{}{text}", self.preamble);
self.with_core(|c| c.prompt(&text));
let continuation = retry.then(|| format!("{}{}", self.preamble, crate::failure::CONTINUE_TURN));
self.with_core(|c| c.queue_prompt(&text, continuation));
}
}
impl Conversation for AcpConversation {
fn last_failure(&self) -> Option<TurnFailure> {
self.failure.or_else(|| self.lock().as_ref().and_then(|s| s.core.failure))
}
fn send(&mut self, text: &str) {
self.send_prompt(text, false);
}
fn retry(&mut self, text: &str) {
self.send_prompt(text, true);
}
fn session_id(&self) -> Option<String> {
@@ -739,6 +763,95 @@ mod tests {
assert_eq!(sent(&c.line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#))[0]["method"], "session/new");
}
#[cfg(unix)]
fn prompt_after_interrupted_acp_turn(recovery: &str) -> String {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let program = dir.path().join("acp-agent");
let script = r#"#!/bin/sh
d=$(dirname "$0")
if [ ! -f "$d/started" ]; then
: > "$d/started"
IFS= read -r init
echo '{"jsonrpc":"2.0","id":1,"result":{"agentCapabilities":{"loadSession":true}}}'
IFS= read -r new
echo '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"saved"}}'
IFS= read -r prompt
printf '%s\n' "$prompt" > "$d/first-prompt"
exit 7
fi
IFS= read -r init
if [ "$1" = unsupported ]; then
echo '{"jsonrpc":"2.0","id":1,"result":{}}'
IFS= read -r new
echo '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"fresh"}}'
prompt_id=3
else
echo '{"jsonrpc":"2.0","id":1,"result":{"agentCapabilities":{"loadSession":true}}}'
IFS= read -r load
printf '%s\n' "$load" > "$d/load"
if [ "$1" = loaded ]; then
echo '{"jsonrpc":"2.0","id":2,"result":{}}'
prompt_id=3
else
echo '{"jsonrpc":"2.0","id":2,"error":{"code":-32603,"message":"session unavailable"}}'
IFS= read -r new
echo '{"jsonrpc":"2.0","id":3,"result":{"sessionId":"fresh"}}'
prompt_id=4
fi
fi
IFS= read -r prompt
printf '%s\n' "$prompt" > "$d/retry-prompt"
printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$prompt_id"
"#;
std::fs::write(&program, script).unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
let mcp = McpCommand { command: "craft-anything-cli".into(), args: vec!["mcp".into()], notes: None };
let mut c = AcpConversation::new("Scripted ACP", "gemini-cli", program.display().to_string(), vec![recovery.into()], dir.path().into(), mcp, None)
.with_preamble("P:".into());
let finish = |c: &mut AcpConversation| {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut all = Vec::new();
while !all.iter().any(|e| matches!(e, AgentEvent::TurnDone { .. })) {
assert!(std::time::Instant::now() < deadline, "no TurnDone: {all:?}");
all.extend(c.poll());
std::thread::sleep(Duration::from_millis(10));
}
all
};
let original = "Build the requested blue header.";
c.send(original);
assert!(finish(&mut c).iter().any(|e| matches!(e, AgentEvent::Error { .. })));
assert_eq!(c.last_failure(), Some(TurnFailure::new(FailureKind::Transient)));
assert_eq!(c.session_id().as_deref(), Some("saved"));
c.retry(original);
let events = finish(&mut c);
assert!(!events.iter().any(|e| matches!(e, AgentEvent::Error { .. })), "{events:?}");
let read = |name: &str| -> Value { serde_json::from_str(&std::fs::read_to_string(dir.path().join(name)).unwrap()).unwrap() };
assert_eq!(read("first-prompt")["params"]["prompt"][0]["text"], format!("P:{original}"));
if recovery != "unsupported" {
assert_eq!(read("load")["params"]["sessionId"], "saved");
}
let retry = read("retry-prompt");
assert_eq!(retry["method"], "session/prompt");
assert_eq!(retry["params"]["sessionId"], if recovery == "loaded" { "saved" } else { "fresh" });
retry["params"]["prompt"][0]["text"].as_str().unwrap().to_string()
}
#[cfg(unix)]
#[test]
fn acp_retry_preserves_the_request_when_recovery_starts_a_new_session() {
for recovery in ["missing", "unsupported"] {
assert_eq!(prompt_after_interrupted_acp_turn(recovery), "P:Build the requested blue header.", "{recovery}");
}
}
#[cfg(unix)]
#[test]
fn acp_retry_continues_a_successfully_loaded_session() {
assert_eq!(prompt_after_interrupted_acp_turn("loaded"), format!("P:{}", crate::failure::CONTINUE_TURN));
}
fn update(c: &mut AcpCore, value: Value) -> Vec<Out> {
c.line(&json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "old", "update": value}}).to_string())
}
+103 -33
View File
@@ -45,7 +45,6 @@ pub const KNOWN: &[(&str, &str, &str)] = &[
("qwen-code", "qwen", "Qwen Code"),
("goose", "goose", "Goose"),
("maki", "maki", "Maki"),
("aider", "aider", "Aider"),
];
pub fn program_for(agent: &str) -> String {
@@ -516,6 +515,38 @@ impl CliConversation {
pub fn session_id(&self) -> Option<String> {
self.session.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
fn send_prompt(&mut self, text: &str, retry: bool) {
if self.busy.swap(true, Ordering::SeqCst) {
let _ = self.tx.send(AgentEvent::Error {
message: "Still working on the last message.".into(),
remediation: Some("Wait for it to finish or press stop.".into()),
log: None,
});
return;
}
self.stop.reset();
self.lost.store(false, Ordering::SeqCst);
*self.failure.lock().unwrap_or_else(PoisonError::into_inner) = None;
let (spec, tx, stop, busy) = (self.spec.clone(), self.tx.clone(), self.stop.clone(), self.busy.clone());
let state = RunState { session: self.session.clone(), verified: self.verified.clone(), lost: self.lost.clone(), failure: self.failure.clone() };
let text = text.to_string();
let spawned = std::thread::Builder::new().name("ai-cli".into()).spawn(move || {
let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_cli(&spec, &text, retry, &tx, &stop, &state))).unwrap_or_else(|_| {
let _ = tx.send(AgentEvent::Error { message: "The agent runner hit a bug and stopped safely.".into(), remediation: None, log: None });
nothing_done(&spec.config.name)
});
end_turn(&busy, done, |e| {
let _ = tx.send(e);
});
});
if spawned.is_err() {
let _ = self.tx.send(AgentEvent::Error { message: "Couldn't start a worker thread.".into(), remediation: None, log: None });
end_turn(&self.busy, nothing_done(&self.spec.config.name), |e| {
let _ = self.tx.send(e);
});
}
}
}
/// The environment an agent program runs with: Claude Code's limits raised for a long build, and
@@ -574,7 +605,7 @@ fn nothing_done(backend: &str) -> AgentEvent {
/// send once the conversation can take the next message. The session it reports is kept for the
/// next message; one it was asked to continue but doesn't have is forgotten and flagged in
/// `state.lost` before the error that says so is sent.
fn run_cli(spec: &CliSpec, text: &str, tx: &Sender<AgentEvent>, stop: &Stop, state: &RunState) -> AgentEvent {
fn run_cli(spec: &CliSpec, text: &str, retry: bool, tx: &Sender<AgentEvent>, stop: &Stop, state: &RunState) -> AgentEvent {
let RunState { session, verified, lost, failure } = state;
let name = spec.config.name.clone();
let agent = spec.config.model.clone();
@@ -608,6 +639,9 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender<AgentEvent>, stop: &Stop, sta
text: format!("{program}'s help doesn't list {} here, so it runs with the plain adapter (prompt in, text out).", needed.join(" / ")),
});
adapter = Arc::new(adapters::Plain);
// A saved session cannot be continued through the adapter that will actually run.
// Forget it before retry chooses between the original request and a continuation.
*session.lock().unwrap_or_else(PoisonError::into_inner) = None;
} else if !ok {
let _ = tx.send(AgentEvent::Status { text: format!("Couldn't confirm that {program} has {}; trying it anyway.", needed.join(" / ")) });
}
@@ -615,6 +649,7 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender<AgentEvent>, stop: &Stop, sta
let preamble = spec.preamble.clone().unwrap_or_else(|| crate::prompt::cli_preamble(spec.beginner));
let mcp = mcp_for(spec, remote.is_some());
let resume = session.lock().unwrap_or_else(PoisonError::into_inner).clone();
let text = if retry && adapter.resumes() && resume.is_some() { crate::failure::CONTINUE_TURN } else { text };
let builder = spec.builder.is_some();
// A question goes only to an agent that can be held to reading (a fallback adapter can't).
if spec.question && !adapter.answers_questions() {
@@ -863,8 +898,7 @@ impl FailureContext {
impl Conversation for CliConversation {
fn retry(&mut self, text: &str) {
let resumable = self.spec.adapters.for_agent(&self.spec.config.model).resumes() && self.session_id().is_some();
self.send(if resumable { crate::failure::CONTINUE_TURN } else { text });
self.send_prompt(text, true);
}
fn last_failure(&self) -> Option<TurnFailure> {
@@ -885,35 +919,7 @@ impl Conversation for CliConversation {
/// and the conversation takes the next message before that's sent: whoever reads it may send
/// the next message at once (a session that wasn't there is followed by a fresh start).
fn send(&mut self, text: &str) {
if self.busy.swap(true, Ordering::SeqCst) {
let _ = self.tx.send(AgentEvent::Error {
message: "Still working on the last message.".into(),
remediation: Some("Wait for it to finish or press stop.".into()),
log: None,
});
return;
}
self.stop.reset();
self.lost.store(false, Ordering::SeqCst);
*self.failure.lock().unwrap_or_else(PoisonError::into_inner) = None;
let (spec, tx, stop, busy) = (self.spec.clone(), self.tx.clone(), self.stop.clone(), self.busy.clone());
let state = RunState { session: self.session.clone(), verified: self.verified.clone(), lost: self.lost.clone(), failure: self.failure.clone() };
let text = text.to_string();
let spawned = std::thread::Builder::new().name("ai-cli".into()).spawn(move || {
let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_cli(&spec, &text, &tx, &stop, &state))).unwrap_or_else(|_| {
let _ = tx.send(AgentEvent::Error { message: "The agent runner hit a bug and stopped safely.".into(), remediation: None, log: None });
nothing_done(&spec.config.name)
});
end_turn(&busy, done, |e| {
let _ = tx.send(e);
});
});
if spawned.is_err() {
let _ = self.tx.send(AgentEvent::Error { message: "Couldn't start a worker thread.".into(), remediation: None, log: None });
end_turn(&self.busy, nothing_done(&self.spec.config.name), |e| {
let _ = self.tx.send(e);
});
}
self.send_prompt(text, false);
}
fn poll(&mut self) -> Vec<AgentEvent> {
@@ -1642,6 +1648,70 @@ mod tests {
assert!(text.contains("Request"), "preamble is passed too");
}
#[cfg(unix)]
#[test]
fn a_retry_uses_the_adapter_selected_after_checking_required_flags() {
retry_after_checking_flags(true);
}
#[cfg(unix)]
#[test]
fn a_first_retry_waits_for_the_actual_adapter_selection() {
retry_after_checking_flags(false);
}
#[cfg(unix)]
fn retry_after_checking_flags(send_first: bool) {
use std::os::unix::fs::PermissionsExt;
for supported in [false, true] {
let dir = tempfile::tempdir().unwrap();
let program = dir.path().join("agy");
let help = if supported { "--output-format stream-json" } else { "old command options" };
let script = format!(
"#!/bin/sh\nd=$(dirname \"$0\")\nif [ \"$1\" = --help ]; then echo '{help}'; exit 0; fi\nprintf '%s\\n' \"$@\" > \"$d/argv\"\necho run >> \"$d/runs\"\nexit 7\n"
);
std::fs::write(&program, script).unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
let config = BackendConfig {
name: "Antigravity".into(),
kind: BackendKind::AgentCli,
model: "antigravity".into(),
base_url: None,
ssh_host: None,
enabled: true,
};
let options = BackendOptions { command: Some(program.display().to_string()), ..Default::default() };
let mut spec = CliSpec::new(config, options, false, None);
spec.preamble = Some("P:".into());
spec.resume = Some("saved".into());
let mut c = CliConversation::new(spec);
let finish = |c: &mut CliConversation| {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut all = Vec::new();
while !all.iter().any(|e| matches!(e, AgentEvent::TurnDone { .. })) {
assert!(std::time::Instant::now() < deadline, "no TurnDone: {all:?}");
all.extend(c.poll());
std::thread::sleep(Duration::from_millis(10));
}
all
};
let original = "Build the requested blue header.";
if send_first {
c.send(original);
let first = finish(&mut c);
assert!(first.iter().any(|e| matches!(e, AgentEvent::Error { .. })), "{first:?}");
}
c.retry(original);
let retried = finish(&mut c);
assert_eq!(retried.iter().any(|e| matches!(e, AgentEvent::Status { text } if text.contains("plain adapter"))), !supported);
let argv = std::fs::read_to_string(dir.path().join("argv")).unwrap();
let expected = format!("P:{}", if supported { crate::failure::CONTINUE_TURN } else { original });
assert_eq!(argv.lines().last(), Some(expected.as_str()), "supported={supported}: {argv}");
assert_eq!(argv.contains("--conversation\nsaved\n"), supported, "{argv}");
assert_eq!(std::fs::read_to_string(dir.path().join("runs")).unwrap(), if send_first { "run\nrun\n" } else { "run\n" });
}
}
/// #402: a 34,641-character message in argv passed Windows' command-line limit (os error 206);
/// Claude Code reads it from stdin instead.
#[cfg(unix)]
+41 -8
View File
@@ -55,14 +55,17 @@ pub fn port_open(base_url: &str, timeout: Duration) -> bool {
addrs.iter().any(|a| TcpStream::connect_timeout(a, timeout).is_ok())
}
/// Words in the names of models that turn text into numbers, rank or transcribe, and can't chat:
/// an embedding model listed first was used for every message and each one failed (#127).
const NOT_CHAT: &[&str] = &["embed", "minilm", "bge-", "rerank", "whisper", "tts"];
/// Task tokens and known model families that turn text into numbers, rank or transcribe.
/// Match the model name, not its publisher: `matts/llama-3-instruct` can chat (#127).
const NOT_CHAT: &[&str] = &["embed", "embedding", "embeddings", "embeddinggemma", "minilm", "bge", "rerank", "reranker", "whisper", "tts", "xtts"];
/// The first model a server lists that can chat (else its first, so something is chosen).
/// The first model a server lists that can chat. A specialised model isn't a chat fallback.
pub(crate) fn chat_model(models: &[String]) -> Option<String> {
let chats = |m: &&String| !NOT_CHAT.iter().any(|w| m.to_ascii_lowercase().contains(w));
models.iter().find(chats).or(models.first()).cloned()
let chats = |m: &&String| {
let name = m.rsplit('/').next().unwrap_or(m).to_ascii_lowercase();
!name.split(|c: char| !c.is_ascii_alphanumeric()).any(|token| NOT_CHAT.contains(&token))
};
models.iter().find(chats).cloned()
}
/// One local server: connect, then list models.
@@ -78,7 +81,11 @@ pub fn probe_local(label: &str, base: &str) -> Option<Detected> {
// No answer in time: it may still be loading a model.
Err(_) => Vec::new(),
};
let model = chat_model(&models).unwrap_or_default();
let model = match chat_model(&models) {
Some(model) => model,
None if models.is_empty() => String::new(),
None => return None,
};
let reason = match models.len() {
0 => format!("Found {label} running at {base}"),
1 => format!("Found {label} running with 1 model"),
@@ -287,12 +294,38 @@ mod tests {
let names = |l: &[&str]| l.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(chat_model(&names(&["nomic-embed-text:latest", "llama3.2:3b"])).as_deref(), Some("llama3.2:3b"));
assert_eq!(chat_model(&names(&["mxbai-embed-large", "all-MiniLM-L6-v2", "qwen3:8b"])).as_deref(), Some("qwen3:8b"));
assert_eq!(chat_model(&names(&["nomic-embed-text"])).as_deref(), Some("nomic-embed-text"), "something is chosen");
assert_eq!(chat_model(&names(&["nomic-embed-text"])), None, "an embedding model cannot answer a chat request");
assert_eq!(chat_model(&[]), None);
let url = crate::tests::serve("200 OK", r#"{"object":"list","data":[{"id":"nomic-embed-text:latest"},{"id":"gemma3:4b"}]}"#);
assert_eq!(probe_local("Ollama", &url).unwrap().suggestion.model, "gemma3:4b");
}
#[test]
fn specialised_only_servers_are_not_offered_for_chat() {
for body in [
r#"{"object":"list","data":[{"id":"nomic-embed-text"}]}"#,
r#"{"object":"list","data":[{"id":"bge-reranker-v2"}]}"#,
r#"{"object":"list","data":[{"id":"whisper-large"}]}"#,
r#"{"object":"list","data":[{"id":"tts-1"}]}"#,
r#"{"object":"list","data":[{"id":"all-MiniLM-L6-v2"}]}"#,
] {
let url = crate::tests::serve("200 OK", body);
assert!(probe_local("Local server", &url).is_none(), "{body} has no chat model");
}
}
#[test]
fn task_markers_do_not_match_publishers_or_unrelated_words() {
for model in ["matts/llama-3-instruct", "embedding-author/qwen-2.5-instruct", "embedded-chat", "whisper/gemma-3"] {
assert_eq!(chat_model(&[model.into()]).as_deref(), Some(model));
}
let url = crate::tests::serve("200 OK", r#"{"object":"list","data":[{"id":"matts/llama-3-instruct"}]}"#);
assert_eq!(probe_local("Local", &url).unwrap().suggestion.model, "matts/llama-3-instruct");
for model in ["vendor/text-embedding-3-small", "nomic-embed-text:latest", "BAAI/bge-m3", "vendor/embeddinggemma-300m", "XTTS-v2"] {
assert!(chat_model(&[model.into()]).is_none(), "{model}");
}
}
/// #83: a server set up in OpenCode (here LM Studio on another port) is offered; hosted
/// services and broken settings aren't.
#[test]
+37
View File
@@ -152,6 +152,8 @@ pub struct SseEvent {
/// Longest single SSE line we accept (protects memory against a hostile server).
const MAX_SSE_LINE: usize = 8 * 1024 * 1024;
/// Most data retained for one event, including newlines between its `data:` fields.
const MAX_SSE_EVENT_DATA: usize = 8 * 1024 * 1024;
/// Parse an SSE stream, calling `f` for each event. `f` returns false to stop early.
/// Handles CRLF, multi-line `data:`, comments (`:`), and a final event without a blank line.
@@ -194,6 +196,10 @@ pub fn parse_sse<R: Read>(r: R, mut f: impl FnMut(SseEvent) -> Result<bool, Tool
match field {
"event" => cur.event = Some(value.to_string()),
"data" => {
let size = cur.data.len().saturating_add(usize::from(has_data)).saturating_add(value.len());
if size > MAX_SSE_EVENT_DATA {
return Err(ToolError::new(ErrorCode::OverLimit, "The stream sent an event whose data was too large."));
}
if has_data {
cur.data.push('\n');
}
@@ -243,6 +249,37 @@ mod tests {
assert_eq!(n, 1);
}
#[test]
fn sse_event_data_is_bounded_across_lines() {
let first = "é".repeat(2 * 1024 * 1024);
let full = format!("data:{first}\ndata:{}\n", "x".repeat(4 * 1024 * 1024 - 1));
for stream in [format!("data:{first}\ndata:{first}\n\n"), format!("data:{first}\ndata:{first}"), format!("{full}data:\n\n")] {
let mut emitted = 0;
let error = parse_sse(stream.as_bytes(), |_| {
emitted += 1;
Ok(true)
})
.unwrap_err();
assert_eq!(error.code, ErrorCode::OverLimit);
assert_eq!(emitted, 0, "an oversized event must not reach the provider parser");
assert!(!error.message.contains('é'), "the error must not echo response data");
}
}
#[test]
fn sse_event_data_limit_preserves_boundaries_and_eof() {
let first = "é".repeat(2 * 1024 * 1024);
let full = format!("data:{first}\r\ndata:{}\r\n", "x".repeat(4 * 1024 * 1024 - 1));
let stream = format!("{full}\r\ndata:\r\n\r\n{full}");
let mut lengths = Vec::new();
parse_sse(stream.as_bytes(), |event| {
lengths.push(event.data.len());
Ok(true)
})
.unwrap();
assert_eq!(lengths, [8 * 1024 * 1024, 0, 8 * 1024 * 1024]);
}
#[test]
fn provider_errors_include_the_nested_reason() {
let reason = "The selected model does not accept this request parameter.";
+3 -1
View File
@@ -417,7 +417,9 @@ impl AiHub {
let model = match model.map(str::trim).filter(|m| !m.is_empty()) {
Some(m) => m.to_string(),
None => detect::chat_model(&models).ok_or_else(|| {
ToolError::new(ErrorCode::NotFound, format!("{address} has no models yet.")).with_remediation("Add a model on the server first, or name one.")
let reason = if models.is_empty() { "has no models yet" } else { "doesn't list a model that can chat" };
ToolError::new(ErrorCode::NotFound, format!("{address} {reason}."))
.with_remediation("Load a chat model on the server first, or enter the name of a chat model you know is available.")
})?,
};
let config = BackendConfig {
+4
View File
@@ -349,6 +349,10 @@ fn a_server_is_added_by_its_address() {
assert_eq!(h.add_server("Named", &url, Some("llama3"), None).unwrap(), "llama3", "a named model is kept");
assert!(h.add_server("Web page", &serve("404 Not Found", "nope"), None, None).is_err(), "not a model server");
assert!(h.add_server("Empty", &serve("200 OK", r#"{"object":"list","data":[]}"#), None, None).is_err(), "no models to use");
let embeddings = serve("200 OK", r#"{"object":"list","data":[{"id":"nomic-embed-text"}]}"#);
let error = h.add_server("Embeddings", &embeddings, None, None).unwrap_err();
assert_eq!(error.code, ErrorCode::NotFound);
assert!(error.message.contains("model that can chat"));
assert!(h.add_server("No scheme", "192.168.1.20:11434/v1", None, None).is_err());
assert!(h.add_server(" ", &url, None, None).is_err());
assert_eq!(h.backends().len(), 2, "only the ones that answered");
+45 -11
View File
@@ -40,7 +40,8 @@ const HOME_VAR: &str = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
/// shell's, and would miss a coding agent installed in `~/.local/bin`; on Windows an installer
/// may not have added its folder to `PATH` at all.
pub fn search_path() -> Option<OsString> {
with_usual_dirs(std::env::var_os("PATH"), std::env::var_os(HOME_VAR).map(PathBuf::from))
let local_data = cfg!(windows).then(|| std::env::var_os("LOCALAPPDATA")).flatten().map(PathBuf::from);
with_usual_dirs(std::env::var_os("PATH"), std::env::var_os(HOME_VAR).map(PathBuf::from), local_data)
}
/// How to start `program`, and the arguments that go before the caller's. On Windows only `.exe`
@@ -145,11 +146,15 @@ fn npmrc_prefix(home: &Path) -> Option<PathBuf> {
})
}
fn with_usual_dirs(path: Option<OsString>, home: Option<PathBuf>) -> Option<OsString> {
fn with_usual_dirs(path: Option<OsString>, home: Option<PathBuf>, local_data: Option<PathBuf>) -> Option<OsString> {
let mut dirs: Vec<PathBuf> = path.as_ref().map(|p| std::env::split_paths(p).collect()).unwrap_or_default();
let before = dirs.len();
let in_home = home.iter().flat_map(|h| USUAL_HOME_DIRS.iter().map(move |d| h.join(d)).chain(nvm_bins(h)).chain(npm_global_bin(h)));
for d in in_home.chain(USUAL_DIRS.iter().map(PathBuf::from)) {
// Antigravity's per-user installer may use a redirected LOCALAPPDATA directory.
let local_data =
local_data.filter(|p| !p.as_os_str().is_empty()).or_else(|| home.as_ref().filter(|_| cfg!(windows)).map(|h| h.join("AppData").join("Local")));
let antigravity = local_data.map(|p| p.join("agy").join("bin"));
for d in in_home.chain(antigravity).chain(USUAL_DIRS.iter().map(PathBuf::from)) {
if !dirs.contains(&d) {
dirs.push(d);
}
@@ -231,7 +236,8 @@ fn candidates(base: &Path) -> Vec<PathBuf> {
let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
for e in exts.split(';').filter(|e| !e.is_empty()) {
let mut s = base.as_os_str().to_os_string();
s.push(e);
// Lower case, as installers name their files; PATHEXT spells them `.EXE`.
s.push(e.to_ascii_lowercase());
v.push(PathBuf::from(s));
}
}
@@ -272,14 +278,14 @@ mod tests {
fn usual_dirs_come_after_path() {
let home = PathBuf::from("/home/u");
let path = std::env::join_paths([PathBuf::from("/usr/bin"), home.join(".local/bin")]).unwrap();
let dirs: Vec<PathBuf> = std::env::split_paths(&with_usual_dirs(Some(path), Some(home.clone())).unwrap()).collect();
let dirs: Vec<PathBuf> = std::env::split_paths(&with_usual_dirs(Some(path), Some(home.clone()), None).unwrap()).collect();
assert_eq!(dirs[..3], [PathBuf::from("/usr/bin"), home.join(".local/bin"), home.join(".claude/local")]);
// (Plus npm's prefix where the environment sets one.)
let npm = usize::from(npm_global_bin(&home).is_some());
assert_eq!(dirs.len(), 1 + USUAL_HOME_DIRS.len() + USUAL_DIRS.len() + npm, "nothing twice");
assert!(dirs.contains(&PathBuf::from("/opt/homebrew/bin")));
// No PATH and no home folder: the system-wide folders still.
assert_eq!(with_usual_dirs(None, None), std::env::join_paths(USUAL_DIRS).ok());
assert_eq!(with_usual_dirs(None, None, None), std::env::join_paths(USUAL_DIRS).ok());
}
/// #112: agents npm installed under nvm, newest Node first, after the usual folders.
@@ -293,7 +299,7 @@ mod tests {
let bins = nvm_bins(home.path());
let node = home.path().join(".nvm/versions/node");
assert_eq!(bins, [node.join("v20.10.0/bin"), node.join("v18.19.1/bin"), node.join("v9.11.2/bin")]);
let dirs: Vec<PathBuf> = std::env::split_paths(&with_usual_dirs(None, Some(home.path().to_path_buf())).unwrap()).collect();
let dirs: Vec<PathBuf> = std::env::split_paths(&with_usual_dirs(None, Some(home.path().to_path_buf()), None).unwrap()).collect();
assert!(dirs.contains(&home.path().join(".opencode/bin")));
let at = |p: &Path| dirs.iter().position(|d| d == p).unwrap();
assert!(at(&home.path().join(".cargo/bin")) < at(&bins[0]) && at(&bins[2]) < at(Path::new("/usr/local/bin")));
@@ -310,7 +316,7 @@ mod tests {
let bin = home.path().join(r"AppData\Local\hermes\bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("hermes.exe"), "").unwrap();
let path = with_usual_dirs(None, Some(home.path().to_path_buf()));
let path = with_usual_dirs(None, Some(home.path().to_path_buf()), None);
assert_eq!(which_in("hermes", path), [bin.join("hermes.exe")]);
}
@@ -330,21 +336,49 @@ mod tests {
if std::env::var_os("NPM_CONFIG_PREFIX").is_none() && std::env::var_os("npm_config_prefix").is_none() {
std::fs::write(&rc, "prefix=~/.npm-packages\n").unwrap();
let bin = if cfg!(windows) { home.path().join(".npm-packages") } else { home.path().join(".npm-packages").join("bin") };
let dirs: Vec<PathBuf> = std::env::split_paths(&with_usual_dirs(None, Some(home.path().to_path_buf())).unwrap()).collect();
let dirs: Vec<PathBuf> = std::env::split_paths(&with_usual_dirs(None, Some(home.path().to_path_buf()), None).unwrap()).collect();
assert!(dirs.contains(&bin), "{dirs:?}");
}
}
#[test]
fn antigravity_is_found_in_redirected_local_app_data() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("profile");
let local_data = dir.path().join("redirected-local-data");
let bin = local_data.join("agy").join("bin");
std::fs::create_dir_all(&bin).unwrap();
let program = bin.join(if cfg!(windows) { "agy.exe" } else { "agy" });
std::fs::write(&program, b"fixture").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path = with_usual_dirs(None, Some(home.clone()), Some(local_data.clone()));
assert_eq!(which_in("agy", path.clone()).as_slice(), std::slice::from_ref(&program));
let dirs: Vec<_> = std::env::split_paths(path.as_ref().unwrap()).collect();
assert!(!dirs.contains(&home.join("AppData").join("Local").join("agy").join("bin")));
// A folder already in PATH retains its position and is not added again.
let explicit = std::env::join_paths([&bin]).unwrap();
let path = with_usual_dirs(Some(explicit), Some(home), Some(local_data)).unwrap();
let dirs: Vec<_> = std::env::split_paths(&path).collect();
assert_eq!(dirs.first(), Some(&bin));
assert_eq!(dirs.iter().filter(|d| *d == &bin).count(), 1);
assert_eq!(which_in("agy", Some(path)), [program]);
}
#[cfg(windows)]
#[test]
fn usual_windows_dirs_come_after_path() {
let home = PathBuf::from(r"C:\Users\u");
let path = OsString::from(r"C:\Windows;C:\Users\u\.local\bin");
let dirs: Vec<PathBuf> = std::env::split_paths(&with_usual_dirs(Some(path), Some(home.clone())).unwrap()).collect();
let dirs: Vec<PathBuf> = std::env::split_paths(&with_usual_dirs(Some(path), Some(home.clone()), None).unwrap()).collect();
assert_eq!(dirs[..3], [PathBuf::from(r"C:\Windows"), home.join(r".local\bin"), home.join(r"AppData\Roaming\npm")]);
// (Plus npm's prefix where the environment sets one.)
let npm = usize::from(npm_global_bin(&home).is_some());
assert_eq!(dirs.len(), 1 + USUAL_HOME_DIRS.len() + USUAL_DIRS.len() + npm, "nothing twice");
assert_eq!(dirs.len(), 2 + USUAL_HOME_DIRS.len() + USUAL_DIRS.len() + npm, "nothing twice");
assert!(dirs.contains(&home.join("AppData").join("Local").join("agy").join("bin")));
// Each way Claude Code installs on Windows: native, npm, WinGet (per user and machine-wide).
assert!(dirs.contains(&home.join(r"AppData\Local\Microsoft\WinGet\Links")));
assert!(dirs.contains(&PathBuf::from(r"C:\Program Files\WinGet\Links")));
+11 -3
View File
@@ -170,6 +170,9 @@ only when it's green and a maintainer has fully reviewed it, code as well as wor
### R5.3 A builder that can't make progress stops
Owner: open · Status: todo
Every round spends the person's money or quota. One that can't do anything shouldn't start.
- [x] Preserve the interrupted request when an agent cannot restore its session: ACP starts a new
session, or a registered CLI adapter falls back to Plain (#552 review).
— claimed: Afloat16, branch bugfix/r5.3-interrupted-request-recovery-20261103
- [x] A build stops and says why after two builder turns in a row that changed nothing in `app/`,
on every backend (#44, #110, #417, #481). The count is saved in the project, so it holds
across runs: a run ends at 7 checks (`max_rounds`) but check numbers carry on, so #44's check
@@ -214,9 +217,13 @@ been run live.
services whose real window differs from 150k (#374) — implementation: `6bd94b84828ec89b805cb8a246f557d0a778c037`
- [ ] Find LM Studio on another port (#73)
- [ ] See why Bionic or LM Studio can't be picked (#140)
- [x] Do not select an embedding, ranking or transcription model as a chat backend when a local server has no chat models — claimed: Afloat16, branch bugfix/r5.4-nonchat-model-detection-20261103
### R5.5 API-key builds fail in words people can use
Owner: open · Status: todo
- [x] Bound accumulated data across all lines of one server-sent event, so short lines cannot
grow a streamed API response without limit
— claimed: Afloat16, branch bugfix/r5.5-bounded-sse-events-20261103
- [x] Show the provider's own reason: #369 bought $10 of credit and got only "OpenRouter answered
400: Provider returned error". Find the reason in the response body and show it — PR #542
- [ ] A live API-key build (needs a key on the test machine)
@@ -349,8 +356,9 @@ Owner: open · Status: in progress
- [ ] OpenCode's plain adapter never passes our tool server, so its builder has none of our tools
and hunts for a setting that doesn't exist (#481). Hand it the server, or build over ACP by
default once that passes
- [ ] Aider has no adapter, so the plain one hands it the prompt as a file name, and it can open a
browser sign-in nobody agreed to (#72). Give it an adapter, or stop offering it
- [x] Aider has no adapter, so the plain one hands it the prompt as a file name, and it can open a
browser sign-in nobody agreed to (#72). Give it an adapter, or stop offering it — claimed: Afloat16, branch bugfix/r2.2-aider-recommendations-20261103
- [ ] Add a non-interactive Aider adapter before recommending it again; detection currently leaves it out.
- [ ] Codex (#499): in a chat the tool server has no active target, tools turned on with
`enable_toolset` can't be called, The
reporter attached a patch for chat continuity; look at that first
@@ -418,7 +426,7 @@ Owner: open · Status: in progress
(#259, a guess at its cause)
- [ ] macOS: `/Applications/Antigravity.app` ended up as the agent's command (#467); find what
writes it (no code for it was found; maybe the file picker)
- [ ] Windows: look in `%LOCALAPPDATA%\agy\bin` too (#62)
- [x] Windows: look in `%LOCALAPPDATA%\agy\bin` too (#62) — claimed: Afloat16, branch bugfix/r2.2-antigravity-search-20261103
- [ ] "Claude Code on <ssh host>" is offered for any SSH host, whether or not it has Claude Code
(#62)
- [ ] Gemini CLI's sign-in reportedly refuses individuals now (#400): check, and stop recommending