9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
230 lines
8.6 KiB
Rust
230 lines
8.6 KiB
Rust
//! Integration test for OpenAI auto-upgrade chat→responses.
|
|
//!
|
|
//! Starts a tiny HTTP server that:
|
|
//! 1. accepts a POST to /chat/completions, replies 400 with a body that
|
|
//! mentions `/v1/responses` (mirrors the Databricks GPT-5.5 signal);
|
|
//! 2. accepts a POST to /responses, replies 200 with a Responses-shaped
|
|
//! JSON envelope.
|
|
//!
|
|
//! Spawns `buzz-agent` with `provider=openai` + `OPENAI_COMPAT_API=auto`
|
|
//! pointed at the fake server, drives one prompt through the ACP wire
|
|
//! protocol, and verifies the prompt completes with `stopReason=end_turn`
|
|
//! — which can only happen if the second (Responses) request succeeded.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpListener;
|
|
use std::process::Stdio;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use serde_json::json;
|
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::process::Command;
|
|
use tokio::time::timeout;
|
|
|
|
/// Spawns a single-shot fake provider. Returns the base URL (e.g.
|
|
/// `http://127.0.0.1:54321`). The server stays up for the lifetime of
|
|
/// the process — we don't need to clean it up explicitly.
|
|
fn spawn_fake_provider() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
listener.set_nonblocking(false).unwrap();
|
|
let url = format!("http://{}", listener.local_addr().unwrap());
|
|
let chat_hits = Arc::new(AtomicUsize::new(0));
|
|
let responses_hits = Arc::new(AtomicUsize::new(0));
|
|
let chat = chat_hits.clone();
|
|
let resp = responses_hits.clone();
|
|
|
|
std::thread::spawn(move || {
|
|
loop {
|
|
let (mut sock, _) = match listener.accept() {
|
|
Ok(p) => p,
|
|
Err(_) => return,
|
|
};
|
|
let chat = chat.clone();
|
|
let resp = resp.clone();
|
|
std::thread::spawn(move || {
|
|
sock.set_read_timeout(Some(Duration::from_secs(5))).ok();
|
|
// Read request head + body. Naive: read until we have the
|
|
// request line + headers, then read Content-Length bytes.
|
|
let mut buf = Vec::with_capacity(4096);
|
|
let mut tmp = [0u8; 4096];
|
|
loop {
|
|
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
|
break;
|
|
}
|
|
match sock.read(&mut tmp) {
|
|
Ok(0) | Err(_) => return,
|
|
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
|
}
|
|
if buf.len() > 256 * 1024 {
|
|
return;
|
|
}
|
|
}
|
|
let head_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
|
|
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
|
|
// Drain the body to satisfy keep-alive; we don't actually
|
|
// need it.
|
|
let cl = head
|
|
.lines()
|
|
.find_map(|l| {
|
|
l.strip_prefix("content-length:")
|
|
.or_else(|| l.strip_prefix("Content-Length:"))
|
|
})
|
|
.and_then(|s| s.trim().parse::<usize>().ok())
|
|
.unwrap_or(0);
|
|
while buf.len() < head_end + cl {
|
|
match sock.read(&mut tmp) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
|
}
|
|
}
|
|
|
|
let (status, body) = if head.contains("POST /chat/completions") {
|
|
chat.fetch_add(1, Ordering::SeqCst);
|
|
let body = json!({
|
|
"error": {
|
|
"code": "BAD_REQUEST",
|
|
"message": "Function tools with reasoning_effort are not supported for gpt-5.5 in /v1/chat/completions. Please use /v1/responses instead."
|
|
}
|
|
})
|
|
.to_string();
|
|
(400u16, body)
|
|
} else if head.contains("POST /responses") {
|
|
resp.fetch_add(1, Ordering::SeqCst);
|
|
let body = json!({
|
|
"status": "completed",
|
|
"output": [{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "ok from responses"}]
|
|
}]
|
|
})
|
|
.to_string();
|
|
(200u16, body)
|
|
} else {
|
|
(404u16, "{}".to_string())
|
|
};
|
|
let reason = match status {
|
|
200 => "OK",
|
|
400 => "Bad Request",
|
|
_ => "Not Found",
|
|
};
|
|
let resp_text = format!(
|
|
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(), body
|
|
);
|
|
let _ = sock.write_all(resp_text.as_bytes());
|
|
let _ = sock.shutdown(std::net::Shutdown::Write);
|
|
});
|
|
}
|
|
});
|
|
|
|
(url, chat_hits, responses_hits)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn openai_auto_upgrades_chat_to_responses_on_databricks_signal() {
|
|
let (base_url, chat_hits, resp_hits) = spawn_fake_provider();
|
|
|
|
let bin = env!("CARGO_BIN_EXE_buzz-agent");
|
|
let mut cmd = Command::new(bin);
|
|
cmd.env("BUZZ_AGENT_PROVIDER", "openai")
|
|
.env("OPENAI_COMPAT_API_KEY", "test")
|
|
.env("OPENAI_COMPAT_MODEL", "gpt-5.5")
|
|
.env("OPENAI_COMPAT_BASE_URL", &base_url)
|
|
// No OPENAI_COMPAT_API — must default to "auto" so the upgrade
|
|
// path is enabled.
|
|
.env_remove("OPENAI_COMPAT_API")
|
|
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
|
|
.env("BUZZ_AGENT_MAX_ROUNDS", "4")
|
|
.env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2")
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::inherit())
|
|
.kill_on_drop(true);
|
|
|
|
let mut child = cmd.spawn().expect("spawn buzz-agent");
|
|
let mut stdin = child.stdin.take().unwrap();
|
|
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
|
|
|
async fn send(stdin: &mut tokio::process::ChildStdin, v: serde_json::Value) {
|
|
let line = format!("{v}\n");
|
|
stdin.write_all(line.as_bytes()).await.unwrap();
|
|
stdin.flush().await.unwrap();
|
|
}
|
|
async fn recv(stdout: &mut BufReader<tokio::process::ChildStdout>) -> serde_json::Value {
|
|
let mut line = String::new();
|
|
timeout(Duration::from_secs(8), stdout.read_line(&mut line))
|
|
.await
|
|
.expect("recv timed out")
|
|
.expect("recv io");
|
|
serde_json::from_str(&line).expect("recv json")
|
|
}
|
|
|
|
send(
|
|
&mut stdin,
|
|
json!({
|
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
"params": {"protocolVersion": 1, "clientCapabilities": {},
|
|
"clientInfo": {"name": "auto-upgrade-test"}}
|
|
}),
|
|
)
|
|
.await;
|
|
let init = recv(&mut stdout).await;
|
|
assert!(init.get("result").is_some(), "initialize: {init}");
|
|
|
|
let cwd = std::env::current_dir().unwrap();
|
|
send(
|
|
&mut stdin,
|
|
json!({
|
|
"jsonrpc": "2.0", "id": 2, "method": "session/new",
|
|
"params": {"cwd": cwd.to_string_lossy(), "mcpServers": []}
|
|
}),
|
|
)
|
|
.await;
|
|
let sess = recv(&mut stdout).await;
|
|
let sid = sess["result"]["sessionId"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("session/new failed: {sess}"))
|
|
.to_string();
|
|
|
|
send(
|
|
&mut stdin,
|
|
json!({
|
|
"jsonrpc": "2.0", "id": 3, "method": "session/prompt",
|
|
"params": {"sessionId": sid,
|
|
"prompt": [{"type": "text", "text": "hi"}]}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Drain notifications until we see the response for id=3.
|
|
let mut stop_reason: Option<String> = None;
|
|
for _ in 0..40 {
|
|
let msg = recv(&mut stdout).await;
|
|
if msg.get("id") == Some(&json!(3)) {
|
|
if let Some(r) = msg.get("result") {
|
|
stop_reason = r
|
|
.get("stopReason")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(stop_reason.as_deref(), Some("end_turn"));
|
|
assert_eq!(
|
|
chat_hits.load(Ordering::SeqCst),
|
|
1,
|
|
"must have tried chat first"
|
|
);
|
|
assert!(
|
|
resp_hits.load(Ordering::SeqCst) >= 1,
|
|
"must have upgraded to responses"
|
|
);
|
|
|
|
drop(stdin);
|
|
let _ = child.wait().await;
|
|
}
|