Some checks failed
build-prerelease / Resolve version stamps (push) Successful in 31s
CI / Format (push) Successful in 38s
CI / Clippy (push) Successful in 3m28s
build-prerelease / Build neuron-blackwell (push) Failing after 6m4s
build-prerelease / Build neuron-ampere (push) Failing after 7m20s
CI / Test (push) Successful in 7m29s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
build-prerelease / Build neuron-ada (push) Failing after 4m57s
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Successful in 4m19s
build-prerelease / Package cortex RPM (push) Successful in 1m24s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
Step 1 of the OpenAI Responses API rollout. Pure refactor — no new
endpoints, no behaviour change on the wire. Lays the seam for
emitting Responses-shaped streaming events from the same harness
output as chat completions in Step 2.
- New `neuron::wire` module tree:
- `wire::event::InferenceEvent` — format-agnostic enum
(Start, TextDelta, ReasoningDelta, Finish) the candle harness
now emits as its native streaming currency.
- `wire::event::FinishReason` — typed reason that maps cleanly
onto OpenAI `finish_reason`, OpenAI Responses `status`, and
Anthropic `stop_reason` strings.
- `wire::openai_chat::project_chat_stream` — async task that
consumes an InferenceEvent receiver and produces a
ChatCompletionChunk receiver, stamping per-request metadata
(id, created, model_id) onto every chunk. Output matches the
pre-refactor wire shape bit-for-bit.
- candle.rs refactored to emit InferenceEvent on its internal
channel through all three streaming paths (CPU
run_inference_streaming, CUDA single-GPU stream_inference_via_worker,
CUDA TP chat_completion_tp_stream). The streaming functions lost
their id/created/model_id parameters since wire-format metadata
now lives in the projector.
- emit_delta + emit_delta_blocking simplified to single-purpose
TextDelta emitters with no wire-format coupling.
- chat_completion_stream wraps the InferenceEvent receiver in
wire_chat::project_chat_stream before returning so the
/v1/chat/completions HTTP handler keeps consuming
ChatCompletionChunks unchanged. External signature preserved.
Also fixes a pre-existing helexa-acp test race (three modules each
declared their own static LOCK for HOME mutation, so cross-module
parallelism flaked tests that read HOME at runtime). Consolidated
onto a single crate-wide path_util::ENV_LOCK.
122 helexa-acp tests + 44 neuron tests pass (5 new wire projection
tests). fmt + clippy --workspace -- -D warnings clean. Ran helexa-acp
suite 3x to confirm the env race is closed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
193 lines
5.9 KiB
Rust
193 lines
5.9 KiB
Rust
//! Path expansion shared across every tool that takes a path.
|
|
//!
|
|
//! Models often emit shell-style paths like `~/git/repo/file.rs` or
|
|
//! `$HOME/notes.md`. ACP's `fs/read_text_file` and friends — and our
|
|
//! own local `std::fs` reads — both want a real absolute path; the
|
|
//! `~` / `$HOME` forms reach them as literal strings and the open
|
|
//! fails. The tool schemas already document "absolute path" but in
|
|
//! practice the model slips up often enough that handling it
|
|
//! server-side is the difference between "works" and "the agent is
|
|
//! brittle".
|
|
//!
|
|
//! Scope is deliberately small:
|
|
//!
|
|
//! - `~` and `~/` (current user only — `~user` lookups would require
|
|
//! pulling in passwd parsing).
|
|
//! - `$HOME` and `$HOME/`.
|
|
//!
|
|
//! Any other shell variable (`$PWD`, `${HOME}`, …) passes through
|
|
//! unchanged. The shell already expands them inside `bash` tool
|
|
//! commands; for the file-tool argument fields, we deliberately
|
|
//! limit the set so the behaviour is predictable.
|
|
//!
|
|
//! Falls back to the input path verbatim when `HOME` is unset
|
|
//! (stripped-down container env). That preserves the "no surprise
|
|
//! mutations" rule — never invent a path the caller didn't ask for.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Process-global lock for tests that mutate `HOME`. Anyone in the
|
|
/// crate touching `HOME` must hold this for the duration of the
|
|
/// read-modify-restore window — otherwise concurrent `cargo test`
|
|
/// workers race and flake.
|
|
///
|
|
/// Only built into the test binaries. Production code never mutates
|
|
/// env vars.
|
|
#[cfg(test)]
|
|
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
|
|
/// Expand `~`, `~/`, `$HOME`, and `$HOME/` prefixes against the
|
|
/// current user's home directory. All other inputs pass through
|
|
/// unchanged.
|
|
///
|
|
/// Returns the input verbatim if `HOME` isn't set in the env.
|
|
pub fn expand_path(input: &Path) -> PathBuf {
|
|
let Some(s) = input.to_str() else {
|
|
return input.to_path_buf();
|
|
};
|
|
let Ok(home) = std::env::var("HOME") else {
|
|
return input.to_path_buf();
|
|
};
|
|
let home = PathBuf::from(home);
|
|
if s == "~" || s == "$HOME" {
|
|
return home;
|
|
}
|
|
if let Some(rest) = s.strip_prefix("~/") {
|
|
return home.join(rest);
|
|
}
|
|
if let Some(rest) = s.strip_prefix("$HOME/") {
|
|
return home.join(rest);
|
|
}
|
|
input.to_path_buf()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Set HOME for the duration of the test. Tests using this run
|
|
/// serially under the crate-wide [`ENV_LOCK`] because env
|
|
/// mutation isn't thread-safe — `cargo test` parallel workers
|
|
/// would race without it.
|
|
fn with_home<F: FnOnce()>(home: &str, body: F) {
|
|
let _g = ENV_LOCK.lock().unwrap();
|
|
let prior = std::env::var("HOME").ok();
|
|
// SAFETY: tests touch process-global env. The mutex
|
|
// serialises access; sub-threads in other test modules
|
|
// touching HOME aren't expected (none in this crate).
|
|
unsafe {
|
|
std::env::set_var("HOME", home);
|
|
}
|
|
body();
|
|
unsafe {
|
|
match prior {
|
|
Some(p) => std::env::set_var("HOME", p),
|
|
None => std::env::remove_var("HOME"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn expands_tilde_slash() {
|
|
with_home("/home/me", || {
|
|
assert_eq!(
|
|
expand_path(Path::new("~/git/repo/file.rs")),
|
|
PathBuf::from("/home/me/git/repo/file.rs")
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn expands_bare_tilde() {
|
|
with_home("/home/me", || {
|
|
assert_eq!(expand_path(Path::new("~")), PathBuf::from("/home/me"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn expands_dollar_home_slash() {
|
|
with_home("/home/me", || {
|
|
assert_eq!(
|
|
expand_path(Path::new("$HOME/notes.md")),
|
|
PathBuf::from("/home/me/notes.md")
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn expands_bare_dollar_home() {
|
|
with_home("/home/me", || {
|
|
assert_eq!(expand_path(Path::new("$HOME")), PathBuf::from("/home/me"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn absolute_path_passes_through() {
|
|
with_home("/home/me", || {
|
|
assert_eq!(
|
|
expand_path(Path::new("/etc/hostname")),
|
|
PathBuf::from("/etc/hostname")
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn relative_path_passes_through() {
|
|
with_home("/home/me", || {
|
|
assert_eq!(
|
|
expand_path(Path::new("src/main.rs")),
|
|
PathBuf::from("src/main.rs")
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn tilde_user_form_not_expanded() {
|
|
// ~other is shell sugar for /home/other and would require
|
|
// passwd parsing to resolve. Out of scope — pass it
|
|
// through and let the open fail with a clear error.
|
|
with_home("/home/me", || {
|
|
assert_eq!(
|
|
expand_path(Path::new("~other/x")),
|
|
PathBuf::from("~other/x")
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn no_home_env_passes_through() {
|
|
// Share the same crate-wide lock as `with_home` — otherwise
|
|
// a parallel test setting HOME races this clear-and-assert
|
|
// window.
|
|
let _g = ENV_LOCK.lock().unwrap();
|
|
let prior = std::env::var("HOME").ok();
|
|
// SAFETY: serialised by LOCK above.
|
|
unsafe {
|
|
std::env::remove_var("HOME");
|
|
}
|
|
assert_eq!(
|
|
expand_path(Path::new("~/git/repo")),
|
|
PathBuf::from("~/git/repo")
|
|
);
|
|
unsafe {
|
|
if let Some(p) = prior {
|
|
std::env::set_var("HOME", p);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dollar_other_var_not_expanded() {
|
|
with_home("/home/me", || {
|
|
assert_eq!(
|
|
expand_path(Path::new("$PWD/file")),
|
|
PathBuf::from("$PWD/file")
|
|
);
|
|
assert_eq!(
|
|
expand_path(Path::new("${HOME}/file")),
|
|
PathBuf::from("${HOME}/file")
|
|
);
|
|
});
|
|
}
|
|
}
|