mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
Move the lithos-llm pin from 55add459 to 43a42ac28e9d9bcf40a91abc02be4f12ca274ebb, and the three Pebble pins from a39f43e to 67c9f48, Pebble `main`, which pins that same lithos-llm revision so Cargo holds one lithos-llm crate. lithos-llm `main` (ca19fac) is one commit further; that commit touches only its nightly workflow, so this pin stays on the revision Pebble unifies with. The `openai`, `anthropic`, `gemini`, and `openai-compatible` features are gone upstream; each expanded to `runtime`, which `bedrock` implies, so the four names leave the fabro-llm feature list. Every other manifest already names `runtime`. The catalog schema now names one adapter and many codecs per provider. `adapter` defaults to `http`, `codecs = [...]` replaces `codec` and defaults to `["openai-chat"]`, and the loader rejects the old `codec` key and the four protocol-named adapter ids. Every inline catalog in tests and docs moves to the new shape: the `openai-compatible` + `openai-chat` pair is dropped as the default, `adapter = "openai"` + `codec = "openai-responses"` becomes `codecs = ["openai-responses"]`, and the one test that swaps in a custom adapter id now adds the line instead of replacing one. The settings reference, the API schema's `Provider.adapter` description, and the SDK page describe the new fields; the three `docs/superpowers/plans/` files that show the old shape are dated, unchecked historical plans and are left as they are. The implied agent profile for an operator provider that declares none used to read the removed protocol adapter ids; it now reads the provider's first codec (Anthropic Messages and Gemini map to their harnesses, the `bedrock` adapter to Anthropic, everything else to OpenAI), with a test for the codec path. Absorbing the rest of the range: OpenRouter and Fireworks now ship enabled, so the two fabro-llm tests that used OpenRouter as the disabled fixture use `bedrock-openai`, and the docs and comments that said the two ship disabled are corrected. The built-in catalog grew past 100 enabled model rows (Vercel, TypeSafe, and the enabled OpenRouter and Fireworks rosters), so the pagination shape test walks `page[offset]` to the last page instead of assuming one page fits. `cargo update -p` on the four crates also re-resolved a few already-locked edges to match the lithos-llm lockfile: `windows-sys` 0.61.2/0.60.2 -> 0.59.0 under dirs-sys, errno, nu-ansi-term, quinn-udp, rustix, rustls-platform-verifier, tempfile, terminal_size, and winapi-util; `windows-core` 0.61.2 -> 0.62.2 under iana-time-zone; `errno` 0.2.8 -> 0.3.14 under signal-hook-registry; and `indexmap` 2.13.0 as a new public dependency of lithos-llm. No package version was added or removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1104 lines
36 KiB
Rust
1104 lines
36 KiB
Rust
#![allow(
|
||
clippy::absolute_paths,
|
||
reason = "This test module prefers explicit type paths over extra imports."
|
||
)]
|
||
#![expect(
|
||
clippy::disallowed_methods,
|
||
clippy::disallowed_types,
|
||
reason = "Integration tests stage fixtures with sync std::fs calls and a blocking TCP server."
|
||
)]
|
||
|
||
use std::io::{Read, Write};
|
||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||
use std::process::Output;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||
use std::thread;
|
||
use std::time::Duration;
|
||
|
||
use chrono::{Duration as ChronoDuration, Utc};
|
||
use fabro_test::{fabro_snapshot, preserve_coverage_env, test_context};
|
||
use httpmock::MockServer;
|
||
use serde_json::json;
|
||
|
||
use crate::support::fatal_error_line;
|
||
|
||
async fn run_success_output(mut cmd: assert_cmd::Command) -> Output {
|
||
tokio::task::spawn_blocking(move || cmd.assert().success().get_output().clone())
|
||
.await
|
||
.expect("blocking command task should complete")
|
||
}
|
||
|
||
struct MidStreamDecodeErrorAnthropicServer {
|
||
addr: SocketAddr,
|
||
request_count: Arc<AtomicUsize>,
|
||
shutdown: Arc<AtomicBool>,
|
||
join_handle: Option<thread::JoinHandle<()>>,
|
||
}
|
||
|
||
impl MidStreamDecodeErrorAnthropicServer {
|
||
fn start() -> Self {
|
||
let listener = TcpListener::bind("127.0.0.1:0").expect("test LLM server should bind");
|
||
let addr = listener
|
||
.local_addr()
|
||
.expect("test LLM server should expose local addr");
|
||
let request_count = Arc::new(AtomicUsize::new(0));
|
||
let shutdown = Arc::new(AtomicBool::new(false));
|
||
let thread_request_count = Arc::clone(&request_count);
|
||
let thread_shutdown = Arc::clone(&shutdown);
|
||
let join_handle = thread::spawn(move || {
|
||
for stream in listener.incoming() {
|
||
if thread_shutdown.load(Ordering::SeqCst) {
|
||
break;
|
||
}
|
||
let Ok(mut stream) = stream else { continue };
|
||
handle_anthropic_test_connection(&mut stream, &thread_request_count);
|
||
}
|
||
});
|
||
|
||
Self {
|
||
addr,
|
||
request_count,
|
||
shutdown,
|
||
join_handle: Some(join_handle),
|
||
}
|
||
}
|
||
|
||
fn base_url(&self) -> String {
|
||
format!("http://{}", self.addr)
|
||
}
|
||
|
||
fn request_count(&self) -> usize {
|
||
self.request_count.load(Ordering::SeqCst)
|
||
}
|
||
}
|
||
|
||
impl Drop for MidStreamDecodeErrorAnthropicServer {
|
||
fn drop(&mut self) {
|
||
self.shutdown.store(true, Ordering::SeqCst);
|
||
let _ = TcpStream::connect(self.addr);
|
||
if let Some(join_handle) = self.join_handle.take() {
|
||
let _ = join_handle.join();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn handle_anthropic_test_connection(stream: &mut TcpStream, request_count: &Arc<AtomicUsize>) {
|
||
let Some(request) = read_http_request(stream) else {
|
||
return;
|
||
};
|
||
if !request.starts_with("POST /v1/messages ") {
|
||
write_http_response(stream, "404 Not Found", "text/plain", "not found");
|
||
return;
|
||
}
|
||
|
||
let attempt = request_count.fetch_add(1, Ordering::SeqCst);
|
||
if attempt == 0 {
|
||
write_chunk_decode_error_response(stream, &partial_anthropic_stream("partial"));
|
||
} else {
|
||
write_http_response(
|
||
stream,
|
||
"200 OK",
|
||
"text/event-stream",
|
||
&complete_anthropic_stream("Recovered"),
|
||
);
|
||
}
|
||
}
|
||
|
||
fn read_http_request(stream: &mut TcpStream) -> Option<String> {
|
||
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
|
||
let mut request = Vec::new();
|
||
let mut body_start_and_len = None;
|
||
|
||
loop {
|
||
let mut buffer = [0_u8; 4096];
|
||
let read = stream.read(&mut buffer).ok()?;
|
||
if read == 0 {
|
||
break;
|
||
}
|
||
request.extend_from_slice(&buffer[..read]);
|
||
|
||
if body_start_and_len.is_none() {
|
||
if let Some(header_end) = find_header_end(&request) {
|
||
body_start_and_len =
|
||
Some((header_end + 4, parse_content_length(&request[..header_end])));
|
||
}
|
||
}
|
||
|
||
if let Some((body_start, body_len)) = body_start_and_len {
|
||
if request.len() >= body_start + body_len {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
Some(String::from_utf8_lossy(&request).into_owned())
|
||
}
|
||
|
||
fn find_header_end(request: &[u8]) -> Option<usize> {
|
||
request.windows(4).position(|window| window == b"\r\n\r\n")
|
||
}
|
||
|
||
fn parse_content_length(headers: &[u8]) -> usize {
|
||
String::from_utf8_lossy(headers)
|
||
.lines()
|
||
.find_map(|line| {
|
||
let (name, value) = line.split_once(':')?;
|
||
name.eq_ignore_ascii_case("content-length")
|
||
.then(|| value.trim().parse::<usize>().ok())
|
||
.flatten()
|
||
})
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
fn write_chunk_decode_error_response(stream: &mut TcpStream, body: &str) {
|
||
let _ = write!(
|
||
stream,
|
||
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\nconnection: close\r\n\r\n{:x}\r\n{body}\r\nnot-hex\r\n",
|
||
body.len()
|
||
);
|
||
let _ = stream.flush();
|
||
}
|
||
|
||
fn write_http_response(stream: &mut TcpStream, status: &str, content_type: &str, body: &str) {
|
||
let _ = write!(
|
||
stream,
|
||
"HTTP/1.1 {status}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||
body.len()
|
||
);
|
||
let _ = stream.flush();
|
||
}
|
||
|
||
fn partial_anthropic_stream(text: &str) -> String {
|
||
anthropic_event(
|
||
"message_start",
|
||
&serde_json::json!({
|
||
"type": "message_start",
|
||
"message": {
|
||
"id": "msg_stream",
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"model": "claude-haiku-4-5",
|
||
"content": [],
|
||
"stop_reason": null,
|
||
"stop_sequence": null,
|
||
"usage": { "input_tokens": 1, "output_tokens": 0 }
|
||
}
|
||
}),
|
||
) + &anthropic_event(
|
||
"content_block_start",
|
||
&serde_json::json!({
|
||
"type": "content_block_start",
|
||
"index": 0,
|
||
"content_block": { "type": "text", "text": "" }
|
||
}),
|
||
) + &anthropic_event(
|
||
"content_block_delta",
|
||
&serde_json::json!({
|
||
"type": "content_block_delta",
|
||
"index": 0,
|
||
"delta": { "type": "text_delta", "text": text }
|
||
}),
|
||
)
|
||
}
|
||
|
||
fn complete_anthropic_stream(text: &str) -> String {
|
||
partial_anthropic_stream(text)
|
||
+ &anthropic_event(
|
||
"content_block_stop",
|
||
&serde_json::json!({
|
||
"type": "content_block_stop",
|
||
"index": 0
|
||
}),
|
||
)
|
||
+ &anthropic_event(
|
||
"message_delta",
|
||
&serde_json::json!({
|
||
"type": "message_delta",
|
||
"delta": {
|
||
"stop_reason": "end_turn",
|
||
"stop_sequence": null
|
||
},
|
||
"usage": { "output_tokens": 1 }
|
||
}),
|
||
)
|
||
+ &anthropic_event(
|
||
"message_stop",
|
||
&serde_json::json!({
|
||
"type": "message_stop"
|
||
}),
|
||
)
|
||
}
|
||
|
||
fn anthropic_event(event: &str, data: &serde_json::Value) -> String {
|
||
format!("event: {event}\ndata: {data}\n\n")
|
||
}
|
||
|
||
#[test]
|
||
fn help() {
|
||
let context = test_context!();
|
||
let mut cmd = context.command();
|
||
cmd.args(["exec", "--help"]);
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: true
|
||
exit_code: 0
|
||
----- stdout -----
|
||
Run an agentic coding session
|
||
|
||
Usage: fabro exec [OPTIONS] <PROMPT>
|
||
|
||
Arguments:
|
||
<PROMPT> Task prompt
|
||
|
||
Options:
|
||
--json Output as JSON [env: FABRO_JSON=]
|
||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||
--provider <PROVIDER> LLM provider (built-in or configured provider ID)
|
||
--model <MODEL> Model name (defaults per provider)
|
||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||
--permissions <PERMISSIONS> Permission level for tool execution [possible values: read-only, read-write, full]
|
||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||
--auto-approve Skip interactive prompts; deny tools outside permission level
|
||
--debug Print LLM request/response debug info to stderr
|
||
--verbose Print tool results, the transcript, and full LLM request/response JSON to stderr
|
||
--skills-dir <SKILLS_DIR> Directory containing skill files (overrides default discovery)
|
||
--output-format <OUTPUT_FORMAT> Output format (text for human-readable, json for NDJSON event stream) [possible values: text, json]
|
||
-h, --help Print help
|
||
----- stderr -----
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_permissions() {
|
||
let context = test_context!();
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.args(["--permissions", "bogus", "test prompt"]);
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: false
|
||
exit_code: 2
|
||
----- stdout -----
|
||
----- stderr -----
|
||
error: invalid value 'bogus' for '--permissions <PERMISSIONS>'
|
||
[possible values: read-only, read-write, full]
|
||
|
||
For more information, try '--help'.
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn no_prompt() {
|
||
let context = test_context!();
|
||
fabro_snapshot!(context.filters(), context.exec_cmd(), @"
|
||
success: false
|
||
exit_code: 2
|
||
----- stdout -----
|
||
----- stderr -----
|
||
error: the following required arguments were not provided:
|
||
<PROMPT>
|
||
|
||
Usage: fabro exec --no-upgrade-check <PROMPT>
|
||
|
||
For more information, try '--help'.
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn exec_uses_user_config_defaults() {
|
||
let context = test_context!();
|
||
context.write_home(
|
||
".fabro/settings.toml",
|
||
"_version = 1\n\n[cli.exec.model]\nprovider = \"openai\"\nname = \"gpt-4.1-mini\"\n\n[cli.exec.agent]\npermissions = \"read-only\"\n\n[cli.output]\nformat = \"json\"\n",
|
||
);
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.arg("test prompt");
|
||
cmd.env_clear();
|
||
preserve_coverage_env!(cmd);
|
||
cmd.env("HOME", &context.home_dir);
|
||
cmd.env("FABRO_STORAGE_DIR", &context.storage_dir);
|
||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
|
||
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: false
|
||
exit_code: 1
|
||
----- stdout -----
|
||
----- stderr -----
|
||
× LLM credentials not configured for provider 'openai'
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn exec_accepts_configured_custom_provider_from_settings() {
|
||
let context = test_context!();
|
||
context.write_home(
|
||
".fabro/settings.toml",
|
||
"_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.agent]\nprofile = \"openai\"\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
|
||
);
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.arg("test prompt");
|
||
cmd.env_clear();
|
||
preserve_coverage_env!(cmd);
|
||
cmd.env("HOME", &context.home_dir);
|
||
cmd.env("FABRO_STORAGE_DIR", &context.storage_dir);
|
||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
|
||
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: false
|
||
exit_code: 1
|
||
----- stdout -----
|
||
----- stderr -----
|
||
× LLM credentials not configured for provider 'acme-aws'
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn exec_server_target_uses_remote_transport_instead_of_local_api_key_resolution() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/completions");
|
||
// Use a non-retriable error so this test covers transport routing
|
||
// without paying the retry backoff cost of a 5xx response.
|
||
then.status(400).body("server-routed-marker");
|
||
});
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.env_clear();
|
||
preserve_coverage_env!(cmd);
|
||
cmd.env("HOME", &context.home_dir);
|
||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
|
||
cmd.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--provider",
|
||
"openai",
|
||
"--model",
|
||
"gpt-5.4-mini",
|
||
"test prompt",
|
||
]);
|
||
|
||
let output = cmd.assert().failure().get_output().clone();
|
||
let stderr = String::from_utf8(output.stderr).expect("valid utf8");
|
||
assert!(
|
||
stderr.contains("server-routed-marker"),
|
||
"expected remote server failure marker, got: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("API key not set"),
|
||
"exec should not fail local API key validation when --server is set: {stderr}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn exec_server_target_accepts_configured_custom_provider_from_settings() {
|
||
let context = test_context!();
|
||
context.write_home(
|
||
".fabro/settings.toml",
|
||
"_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.agent]\nprofile = \"openai\"\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
|
||
);
|
||
let server = MockServer::start();
|
||
server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/completions");
|
||
then.status(400).body("custom-server-routed-marker");
|
||
});
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.env_clear();
|
||
preserve_coverage_env!(cmd);
|
||
cmd.env("HOME", &context.home_dir);
|
||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
|
||
cmd.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"test prompt",
|
||
]);
|
||
|
||
let output = cmd.assert().failure().get_output().clone();
|
||
let stderr = String::from_utf8(output.stderr).expect("valid utf8");
|
||
assert!(
|
||
stderr.contains("custom-server-routed-marker"),
|
||
"expected remote server failure marker, got: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("unknown provider: acme-aws"),
|
||
"exec should resolve custom providers from settings for remote transport: {stderr}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn exec_configured_server_target_alone_does_not_reroute_exec() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/completions");
|
||
then.status(500).body("config-should-not-be-used");
|
||
});
|
||
context.set_http_target(&server.base_url());
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.env_clear();
|
||
preserve_coverage_env!(cmd);
|
||
cmd.env("HOME", &context.home_dir);
|
||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
|
||
cmd.args([
|
||
"--provider",
|
||
"openai",
|
||
"--model",
|
||
"gpt-5.4-mini",
|
||
"test prompt",
|
||
]);
|
||
|
||
let output = cmd.assert().failure().get_output().clone();
|
||
let stderr = String::from_utf8(output.stderr).expect("valid utf8");
|
||
assert!(
|
||
stderr.contains("LLM credentials not configured for provider 'openai'"),
|
||
"expected local credential resolution failure, got: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("config-should-not-be-used"),
|
||
"exec should ignore configured server.target without --server: {stderr}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn exec_cli_server_target_overrides_configured_server_target() {
|
||
let context = test_context!();
|
||
let config_server = MockServer::start();
|
||
config_server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/completions");
|
||
then.status(500).body("config-should-not-be-used");
|
||
});
|
||
let cli_server = MockServer::start();
|
||
cli_server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/completions");
|
||
// Use a non-retriable error so this test covers target precedence
|
||
// without paying the retry backoff cost of a 5xx response.
|
||
then.status(400).body("cli-override-marker");
|
||
});
|
||
context.set_http_target(&config_server.base_url());
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.env_clear();
|
||
preserve_coverage_env!(cmd);
|
||
cmd.env("HOME", &context.home_dir);
|
||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
|
||
cmd.args([
|
||
"--server",
|
||
&format!("{}/api/v1", cli_server.base_url()),
|
||
"--provider",
|
||
"openai",
|
||
"--model",
|
||
"gpt-5.4-mini",
|
||
"test prompt",
|
||
]);
|
||
|
||
let output = cmd.assert().failure().get_output().clone();
|
||
let stderr = String::from_utf8(output.stderr).expect("valid utf8");
|
||
assert!(
|
||
stderr.contains("cli-override-marker"),
|
||
"expected CLI server target to win, got: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("config-should-not-be-used"),
|
||
"configured server.target should not be used when --server is passed: {stderr}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn exec_server_target_uses_saved_cli_auth_without_local_api_key_resolution() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
server.mock(|when, then| {
|
||
when.method("POST")
|
||
.path("/api/v1/completions")
|
||
.header("authorization", "Bearer access-oauth");
|
||
then.status(400).body("oauth-routed-marker");
|
||
});
|
||
write_auth_entry(
|
||
&context,
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"access-oauth",
|
||
"refresh-oauth",
|
||
);
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.env_clear();
|
||
preserve_coverage_env!(cmd);
|
||
cmd.env("HOME", &context.home_dir);
|
||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
|
||
cmd.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--provider",
|
||
"openai",
|
||
"--model",
|
||
"gpt-5.4-mini",
|
||
"test prompt",
|
||
]);
|
||
|
||
let output = cmd.assert().failure().get_output().clone();
|
||
let stderr = String::from_utf8(output.stderr).expect("valid utf8");
|
||
assert!(
|
||
stderr.contains("oauth-routed-marker"),
|
||
"expected exec to use stored CLI auth for remote transport, got: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("API key not set"),
|
||
"exec should not fall back to local provider auth when stored CLI auth exists: {stderr}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn exec_server_target_auth_failure_exits_with_4() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/completions");
|
||
then.status(401)
|
||
.header("Content-Type", "application/json")
|
||
.json_body(json!({
|
||
"errors": [{
|
||
"status": "401",
|
||
"title": "Unauthorized",
|
||
"detail": "Authentication required.",
|
||
"code": "authentication_required"
|
||
}]
|
||
}));
|
||
});
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.env_clear();
|
||
preserve_coverage_env!(cmd);
|
||
cmd.env("HOME", &context.home_dir);
|
||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
|
||
cmd.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--provider",
|
||
"openai",
|
||
"--model",
|
||
"gpt-5.4-mini",
|
||
"test prompt",
|
||
]);
|
||
|
||
let output = cmd.assert().failure().get_output().clone();
|
||
assert_eq!(output.status.code(), Some(4));
|
||
assert_eq!(
|
||
fatal_error_line(&output.stderr),
|
||
"LLM error: Authentication required."
|
||
);
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
let stderr = console::strip_ansi_codes(&stderr);
|
||
assert!(
|
||
stderr.contains("Run `fabro auth login` to authenticate."),
|
||
"auth failures should retain the login help footer:\n{stderr}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn exec_direct_provider_auth_failure_stays_exit_1() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
server.mock(|when, then| {
|
||
when.method("POST")
|
||
.path("/v1/messages")
|
||
.header("x-api-key", "test-key");
|
||
then.status(401)
|
||
.header("Content-Type", "application/json")
|
||
.json_body(json!({
|
||
"error": {
|
||
"type": "authentication_error",
|
||
"message": "bad key"
|
||
}
|
||
}));
|
||
});
|
||
|
||
// Override anthropic base_url via settings to point to the mock server
|
||
context.write_home(
|
||
".fabro/settings.toml",
|
||
format!(
|
||
"_version = 1\n\n[llm.providers.anthropic]\nbase_url = \"{}/v1\"\n",
|
||
server.base_url()
|
||
),
|
||
);
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.env_clear();
|
||
preserve_coverage_env!(cmd);
|
||
cmd.env("HOME", &context.home_dir);
|
||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||
.env("FABRO_HTTP_PROXY_POLICY", "disabled")
|
||
.env("ANTHROPIC_API_KEY", "test-key");
|
||
cmd.args([
|
||
"--provider",
|
||
"anthropic",
|
||
"--model",
|
||
"claude-haiku-4-5",
|
||
"test prompt",
|
||
]);
|
||
|
||
let output = cmd.assert().failure().get_output().clone();
|
||
assert_eq!(output.status.code(), Some(1));
|
||
assert_eq!(
|
||
fatal_error_line(&output.stderr),
|
||
"LLM error: provider anthropic bad key"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn exec_retries_retryable_mid_stream_body_decode_error() {
|
||
let context = test_context!();
|
||
let llm_server = MidStreamDecodeErrorAnthropicServer::start();
|
||
context.write_home(
|
||
".fabro/settings.toml",
|
||
format!(
|
||
"_version = 1\n\n[llm.providers.anthropic]\nbase_url = \"{}/v1\"\n",
|
||
llm_server.base_url()
|
||
),
|
||
);
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
cmd.env_clear();
|
||
preserve_coverage_env!(cmd);
|
||
cmd.env("HOME", &context.home_dir);
|
||
cmd.env("FABRO_STORAGE_DIR", &context.storage_dir);
|
||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||
.env("FABRO_HTTP_PROXY_POLICY", "disabled")
|
||
.env("ANTHROPIC_API_KEY", "test-key");
|
||
cmd.args([
|
||
"--provider",
|
||
"anthropic",
|
||
"--model",
|
||
"claude-haiku-4-5",
|
||
"--permissions",
|
||
"read-only",
|
||
"Say exactly: Recovered",
|
||
]);
|
||
|
||
let output = cmd.output().expect("command should execute");
|
||
assert!(
|
||
output.status.success(),
|
||
"exec should retry the failed LLM stream and succeed after the second response; requests: {}\nstdout:\n{}\nstderr:\n{}",
|
||
llm_server.request_count(),
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
assert_eq!(
|
||
llm_server.request_count(),
|
||
2,
|
||
"exec should retry the retryable mid-stream body decode error"
|
||
);
|
||
}
|
||
|
||
fn write_auth_entry(
|
||
context: &fabro_test::TestContext,
|
||
target: &str,
|
||
access_token: &str,
|
||
refresh_token: &str,
|
||
) {
|
||
let now = Utc::now();
|
||
let canonical = target
|
||
.trim_end_matches('/')
|
||
.trim_end_matches("/api/v1")
|
||
.to_ascii_lowercase();
|
||
context.write_home(
|
||
".fabro/auth.json",
|
||
serde_json::to_string_pretty(&json!({
|
||
"servers": {
|
||
canonical: {
|
||
"kind": "oauth",
|
||
"access_token": access_token,
|
||
"access_token_expires_at": (now + ChronoDuration::minutes(10)).to_rfc3339(),
|
||
"refresh_token": refresh_token,
|
||
"refresh_token_expires_at": (now + ChronoDuration::days(30)).to_rfc3339(),
|
||
"subject": {
|
||
"idp_issuer": "https://github.com",
|
||
"idp_subject": "12345",
|
||
"login": "octocat",
|
||
"name": "The Octocat",
|
||
"email": "octocat@example.com"
|
||
},
|
||
"logged_in_at": now.to_rfc3339(),
|
||
}
|
||
}
|
||
}))
|
||
.expect("auth file should serialize"),
|
||
);
|
||
}
|
||
|
||
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
|
||
fn exec_creates_file() {
|
||
let context = test_context!();
|
||
context
|
||
.exec_cmd()
|
||
.args([
|
||
"--auto-approve",
|
||
"--permissions",
|
||
"full",
|
||
"--provider",
|
||
"anthropic",
|
||
"--model",
|
||
"claude-haiku-4-5",
|
||
"Create a file called hello.txt containing exactly 'Hello'",
|
||
])
|
||
.timeout(std::time::Duration::from_mins(2))
|
||
.assert()
|
||
.success();
|
||
let path = context.temp_dir.join("hello.txt");
|
||
assert!(path.exists(), "hello.txt should have been created");
|
||
let content = std::fs::read_to_string(&path).expect("read hello.txt");
|
||
assert!(
|
||
content.contains("Hello"),
|
||
"Expected 'Hello' in hello.txt, got: {content}"
|
||
);
|
||
}
|
||
|
||
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
|
||
fn exec_shell_command() {
|
||
let context = test_context!();
|
||
context
|
||
.exec_cmd()
|
||
.args([
|
||
"--auto-approve",
|
||
"--permissions",
|
||
"full",
|
||
"--provider",
|
||
"anthropic",
|
||
"--model",
|
||
"claude-haiku-4-5",
|
||
"Run the shell command `echo arc_test_marker_42` and tell me what it printed",
|
||
])
|
||
.timeout(std::time::Duration::from_mins(2))
|
||
.assert()
|
||
.success();
|
||
}
|
||
|
||
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
|
||
fn exec_read_only_blocks_write() {
|
||
let context = test_context!();
|
||
context
|
||
.exec_cmd()
|
||
.args([
|
||
"--auto-approve",
|
||
"--permissions",
|
||
"read-only",
|
||
"--provider",
|
||
"anthropic",
|
||
"--model",
|
||
"claude-haiku-4-5",
|
||
"Create a file called forbidden.txt containing 'should not exist'",
|
||
])
|
||
.timeout(std::time::Duration::from_mins(2))
|
||
.assert()
|
||
.success();
|
||
assert!(
|
||
!context.temp_dir.join("forbidden.txt").exists(),
|
||
"forbidden.txt should NOT exist under read-only permissions"
|
||
);
|
||
}
|
||
|
||
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
|
||
fn exec_json_output_format() {
|
||
let context = test_context!();
|
||
let output = context
|
||
.exec_cmd()
|
||
.args([
|
||
"--auto-approve",
|
||
"--permissions",
|
||
"full",
|
||
"--output-format",
|
||
"json",
|
||
"--provider",
|
||
"anthropic",
|
||
"--model",
|
||
"claude-haiku-4-5",
|
||
"Create a file called test.txt containing 'test'",
|
||
])
|
||
.timeout(std::time::Duration::from_mins(2))
|
||
.assert()
|
||
.success()
|
||
.get_output()
|
||
.stdout
|
||
.clone();
|
||
let stdout = String::from_utf8(output).expect("valid utf8");
|
||
assert!(!stdout.trim().is_empty(), "json output should not be empty");
|
||
// Every non-empty line should be valid JSON
|
||
let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
|
||
assert!(!lines.is_empty(), "should have at least one NDJSON line");
|
||
let first: serde_json::Value =
|
||
serde_json::from_str(lines[0]).expect("first line should be valid JSON");
|
||
assert!(
|
||
first.get("event").is_some() || first.get("type").is_some(),
|
||
"NDJSON line should have an event or type field, got: {first}"
|
||
);
|
||
}
|
||
|
||
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
|
||
fn exec_read_and_edit() {
|
||
let context = test_context!();
|
||
context.write_temp("data.txt", "old content");
|
||
context
|
||
.exec_cmd()
|
||
.args([
|
||
"--auto-approve",
|
||
"--permissions",
|
||
"full",
|
||
"--provider",
|
||
"anthropic",
|
||
"--model",
|
||
"claude-haiku-4-5",
|
||
"Read data.txt then replace its entire content with 'new content'",
|
||
])
|
||
.timeout(std::time::Duration::from_mins(2))
|
||
.assert()
|
||
.success();
|
||
let content =
|
||
std::fs::read_to_string(context.temp_dir.join("data.txt")).expect("read data.txt");
|
||
assert!(
|
||
content.contains("new content"),
|
||
"Expected 'new content' in data.txt, got: {content}"
|
||
);
|
||
}
|
||
|
||
#[fabro_macros::e2e_test(twin)]
|
||
async fn twin_exec_creates_file() {
|
||
let context = test_context!();
|
||
let twin = fabro_test::twin_openai().await;
|
||
let namespace = format!("{}::{}", module_path!(), line!());
|
||
fabro_test::TwinScenarios::new(namespace.clone())
|
||
.scenario(
|
||
fabro_test::TwinScenario::responses("gpt-5.4-mini")
|
||
.input_contains("Create a file called hello.txt containing exactly 'Hello'")
|
||
.tool_call(fabro_test::TwinToolCall::write_file("hello.txt", "Hello"))
|
||
.text("Done."),
|
||
)
|
||
.load(twin)
|
||
.await;
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
twin.configure_command(&mut cmd, &namespace);
|
||
cmd.args([
|
||
"--auto-approve",
|
||
"--permissions",
|
||
"full",
|
||
"--provider",
|
||
"openai",
|
||
"--model",
|
||
"gpt-5.4-mini",
|
||
"Create a file called hello.txt containing exactly 'Hello'",
|
||
]);
|
||
let _output = run_success_output(cmd).await;
|
||
|
||
let content =
|
||
std::fs::read_to_string(context.temp_dir.join("hello.txt")).expect("read hello.txt");
|
||
assert_eq!(content, "Hello");
|
||
}
|
||
|
||
#[fabro_macros::e2e_test(twin)]
|
||
async fn twin_exec_shell_command() {
|
||
let context = test_context!();
|
||
let twin = fabro_test::twin_openai().await;
|
||
let namespace = format!("{}::{}", module_path!(), line!());
|
||
fabro_test::TwinScenarios::new(namespace.clone())
|
||
.scenario(
|
||
fabro_test::TwinScenario::responses("gpt-5.4-mini")
|
||
.input_contains(
|
||
"Run the shell command `echo hello_from_shell` and tell me what it printed",
|
||
)
|
||
.tool_call(fabro_test::TwinToolCall::shell("echo hello_from_shell")),
|
||
)
|
||
.scenario(
|
||
fabro_test::TwinScenario::responses("gpt-5.4-mini")
|
||
.text("It printed hello_from_shell."),
|
||
)
|
||
.load(twin)
|
||
.await;
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
twin.configure_command(&mut cmd, &namespace);
|
||
cmd.args([
|
||
"--auto-approve",
|
||
"--permissions",
|
||
"full",
|
||
"--provider",
|
||
"openai",
|
||
"--model",
|
||
"gpt-5.4-mini",
|
||
"Run the shell command `echo hello_from_shell` and tell me what it printed",
|
||
]);
|
||
let output = run_success_output(cmd).await;
|
||
let stdout = String::from_utf8(output.stdout).expect("valid utf8");
|
||
let stderr = String::from_utf8(output.stderr).expect("valid utf8");
|
||
let stderr = console::strip_ansi_codes(&stderr);
|
||
assert_eq!(
|
||
stdout, "It printed hello_from_shell.\n",
|
||
"without --verbose, stdout carries the final answer only; stderr:\n{stderr}"
|
||
);
|
||
for absent in ["[result]", "[reasoning]", "[verbose]"] {
|
||
assert!(
|
||
!stderr.contains(absent),
|
||
"without --verbose, stderr should not carry {absent}:\n{stderr}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[fabro_macros::e2e_test(twin)]
|
||
async fn twin_exec_verbose_prints_tool_calls_and_results() {
|
||
let context = test_context!();
|
||
let twin = fabro_test::twin_openai().await;
|
||
let namespace = format!("{}::{}", module_path!(), line!());
|
||
fabro_test::TwinScenarios::new(namespace.clone())
|
||
.scenario(
|
||
fabro_test::TwinScenario::responses("gpt-5.4-mini")
|
||
.input_contains(
|
||
"Run the shell command `echo verbose_marker` and tell me what it printed",
|
||
)
|
||
.tool_call(fabro_test::TwinToolCall::shell("echo verbose_marker")),
|
||
)
|
||
.scenario(
|
||
fabro_test::TwinScenario::responses("gpt-5.4-mini").text("It printed verbose_marker."),
|
||
)
|
||
.load(twin)
|
||
.await;
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
twin.configure_command(&mut cmd, &namespace);
|
||
cmd.args([
|
||
"--auto-approve",
|
||
"--permissions",
|
||
"full",
|
||
"--verbose",
|
||
"--provider",
|
||
"openai",
|
||
"--model",
|
||
"gpt-5.4-mini",
|
||
"Run the shell command `echo verbose_marker` and tell me what it printed",
|
||
]);
|
||
let output = run_success_output(cmd).await;
|
||
let stdout = String::from_utf8(output.stdout).expect("valid utf8");
|
||
let stderr = String::from_utf8(output.stderr).expect("valid utf8");
|
||
let stderr = console::strip_ansi_codes(&stderr);
|
||
assert_eq!(
|
||
stdout, "It printed verbose_marker.\n",
|
||
"--verbose should leave the answer on stdout unchanged; stderr:\n{stderr}"
|
||
);
|
||
// Pebble prints the call's arguments in full under its `[tool]` line and
|
||
// what the call answered under a `[result]` line; fabro's middleware
|
||
// still dumps each model request. (A streamed response is not dumped.)
|
||
for expected in [
|
||
"[tool] shell\n",
|
||
"\"command\": \"echo verbose_marker\"",
|
||
"[result] shell\n",
|
||
"[verbose] request:",
|
||
] {
|
||
assert!(
|
||
stderr.contains(expected),
|
||
"--verbose should print {expected:?} on stderr, got:\n{stderr}"
|
||
);
|
||
}
|
||
let (_, result) = stderr
|
||
.split_once("[result] shell\n")
|
||
.expect("the result block should follow the tool line");
|
||
assert!(
|
||
result.contains("verbose_marker"),
|
||
"the [result] block should carry what the shell printed, got:\n{result}"
|
||
);
|
||
}
|
||
|
||
#[fabro_macros::e2e_test(twin)]
|
||
async fn twin_exec_json_output() {
|
||
let context = test_context!();
|
||
let twin = fabro_test::twin_openai().await;
|
||
let namespace = format!("{}::{}", module_path!(), line!());
|
||
fabro_test::TwinScenarios::new(namespace.clone())
|
||
.scenario(
|
||
fabro_test::TwinScenario::responses("gpt-5.4-mini")
|
||
.input_contains("Create a file called test.txt containing 'test'")
|
||
.tool_call(fabro_test::TwinToolCall::write_file("test.txt", "test"))
|
||
.text("Done."),
|
||
)
|
||
.load(twin)
|
||
.await;
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
twin.configure_command(&mut cmd, &namespace);
|
||
cmd.args([
|
||
"--auto-approve",
|
||
"--permissions",
|
||
"full",
|
||
"--output-format",
|
||
"json",
|
||
"--provider",
|
||
"openai",
|
||
"--model",
|
||
"gpt-5.4-mini",
|
||
"Create a file called test.txt containing 'test'",
|
||
]);
|
||
let output = run_success_output(cmd).await;
|
||
let stdout = String::from_utf8(output.stdout).expect("valid utf8");
|
||
let lines: Vec<&str> = stdout
|
||
.lines()
|
||
.filter(|line| !line.trim().is_empty())
|
||
.collect();
|
||
assert!(!lines.is_empty(), "json output should not be empty");
|
||
let parsed: Vec<serde_json::Value> = lines
|
||
.iter()
|
||
.map(|line| serde_json::from_str(line).expect("each line should be valid JSON"))
|
||
.collect();
|
||
let first = &parsed[0];
|
||
assert!(
|
||
first.get("event").is_some() || first.get("type").is_some(),
|
||
"NDJSON line should have an event or type field, got: {first}"
|
||
);
|
||
}
|
||
|
||
#[fabro_macros::e2e_test(twin)]
|
||
async fn twin_exec_read_and_edit() {
|
||
let context = test_context!();
|
||
context.write_temp("data.txt", "old content");
|
||
let twin = fabro_test::twin_openai().await;
|
||
let namespace = format!("{}::{}", module_path!(), line!());
|
||
fabro_test::TwinScenarios::new(namespace.clone())
|
||
.scenario(
|
||
fabro_test::TwinScenario::responses("gpt-5.4-mini")
|
||
.input_contains("Read data.txt then replace its entire content with 'new content'")
|
||
.tool_call(fabro_test::TwinToolCall::read_file("data.txt")),
|
||
)
|
||
.scenario(
|
||
fabro_test::TwinScenario::responses("gpt-5.4-mini")
|
||
.tool_call(fabro_test::TwinToolCall::write_file(
|
||
"data.txt",
|
||
"new content",
|
||
))
|
||
.text("Done."),
|
||
)
|
||
.load(twin)
|
||
.await;
|
||
|
||
let mut cmd = context.exec_cmd();
|
||
twin.configure_command(&mut cmd, &namespace);
|
||
cmd.args([
|
||
"--auto-approve",
|
||
"--permissions",
|
||
"full",
|
||
"--provider",
|
||
"openai",
|
||
"--model",
|
||
"gpt-5.4-mini",
|
||
"Read data.txt then replace its entire content with 'new content'",
|
||
]);
|
||
let _output = run_success_output(cmd).await;
|
||
|
||
let content =
|
||
std::fs::read_to_string(context.temp_dir.join("data.txt")).expect("read data.txt");
|
||
assert_eq!(content, "new content");
|
||
}
|