docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
//! ACP stdio clients for testing kigi sessions end-to-end: the typed
|
||||
//! [`KigiStdioClient`] (`agent-client-protocol::ClientSideConnection` —
|
||||
//! authentication, session lifecycle, permissions, notification streaming) and
|
||||
//! the raw-wire [`RawStdioClient`] (verbatim JSON-RPC lines for shapes the
|
||||
//! typed client can't produce), plus the shared subprocess spawn/stderr-capture
|
||||
//! plumbing used by every harness in this crate.
|
||||
//! ACP stdio clients for driving `kigi agent stdio` end-to-end: the typed
|
||||
//! [`KigiStdioClient`] and the raw-wire [`RawStdioClient`], which emits
|
||||
//! verbatim JSON-RPC lines for shapes the typed client cannot produce.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -20,12 +17,10 @@ use crate::headless::stderr_tail;
|
||||
use crate::mock_server::MockInferenceServer;
|
||||
use crate::process::spawn_piped_with_stderr_capture;
|
||||
|
||||
/// Spawn `kigi agent stdio` with the canonical hermetic test env: the sandbox
|
||||
/// from [`test_env_cmd_tokio`] plus the debug-logging kill-list, so the
|
||||
/// hermeticity setup exists exactly once for the typed ([`KigiStdioClient`])
|
||||
/// and raw ([`RawStdioClient`]) harnesses. `leading_args` go before the
|
||||
/// `agent stdio` subcommand (global flags); `extra_env` is applied after the
|
||||
/// kill-list so a test can still set e.g. `KIGI_DEBUG_LOG=1` explicitly.
|
||||
/// Spawn `kigi agent stdio` with the hermetic test env. `leading_args` go
|
||||
/// before the `agent stdio` subcommand (global flags); `extra_env` is applied
|
||||
/// after the debug-logging kill-list so a test can still set e.g.
|
||||
/// `KIGI_DEBUG_LOG=1` explicitly.
|
||||
fn spawn_agent_process(
|
||||
server: &MockInferenceServer,
|
||||
cwd: &Path,
|
||||
@@ -40,9 +35,8 @@ fn spawn_agent_process(
|
||||
.args(["agent", "stdio"])
|
||||
.current_dir(cwd);
|
||||
test_env_cmd_tokio(&mut cmd, &server.url(), home);
|
||||
// Hermetic firehose env: clear inherited debug-logging knobs so a test
|
||||
// controls logging only via `extra_env` / `leading_args` (mirrors the
|
||||
// headless `debug_cmd`).
|
||||
// Clear inherited debug-logging knobs so a test controls logging only via
|
||||
// `extra_env` / `leading_args`.
|
||||
for k in [
|
||||
"KIGI_DEBUG_LOG",
|
||||
"KIGI_LOG_FILE",
|
||||
@@ -64,7 +58,7 @@ struct TextCapture {
|
||||
notification_count: AtomicU32,
|
||||
}
|
||||
|
||||
/// ACP client impl: auto-approves permissions, captures text chunks.
|
||||
/// ACP client that auto-approves every permission request.
|
||||
struct TestAcpClient {
|
||||
capture: Arc<TextCapture>,
|
||||
}
|
||||
@@ -75,7 +69,6 @@ impl acp::Client for TestAcpClient {
|
||||
&self,
|
||||
args: acp::RequestPermissionRequest,
|
||||
) -> acp::Result<acp::RequestPermissionResponse> {
|
||||
// Auto-approve: pick AllowOnce if available, otherwise first option.
|
||||
let outcome = args
|
||||
.options
|
||||
.iter()
|
||||
@@ -107,10 +100,9 @@ impl acp::Client for TestAcpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives `kigi agent stdio` via the ACP protocol over pipes.
|
||||
///
|
||||
/// Handles the full lifecycle: spawn → initialize → authenticate → session → prompt.
|
||||
/// Child process is killed on drop.
|
||||
/// Drives `kigi agent stdio` via the ACP protocol over pipes: spawn →
|
||||
/// initialize → authenticate → session → prompt. The child process is killed
|
||||
/// on drop.
|
||||
pub struct KigiStdioClient {
|
||||
conn: acp::ClientSideConnection,
|
||||
_child: tokio::process::Child,
|
||||
@@ -130,8 +122,7 @@ impl KigiStdioClient {
|
||||
}
|
||||
|
||||
/// Like [`spawn_with_home`] but applies extra environment variables to the
|
||||
/// child process (after the standard test env). Used by tests that toggle
|
||||
/// behavior via env vars (e.g. the vendor-compat suite).
|
||||
/// child process, after the standard test env.
|
||||
pub async fn spawn_with_home_and_env(
|
||||
server: &MockInferenceServer,
|
||||
cwd: &Path,
|
||||
@@ -142,8 +133,8 @@ impl KigiStdioClient {
|
||||
}
|
||||
|
||||
/// Like [`spawn_with_home_and_env`] but also prepends `leading_args` before
|
||||
/// the `agent stdio` subcommand. Used to drive top-level global flags (e.g.
|
||||
/// `--debug`) so a test can exercise the flag's master switch, not just env.
|
||||
/// the `agent stdio` subcommand, so a test can exercise top-level global
|
||||
/// flags such as `--debug` rather than only their env equivalents.
|
||||
pub async fn spawn_with_home_env_and_args(
|
||||
server: &MockInferenceServer,
|
||||
cwd: &Path,
|
||||
@@ -237,7 +228,6 @@ impl KigiStdioClient {
|
||||
resp.session_id
|
||||
}
|
||||
|
||||
/// Create a session with a specific model pre-selected.
|
||||
pub async fn create_session_with_model(&self, cwd: &Path, model_id: &str) -> acp::SessionId {
|
||||
let resp = self
|
||||
.conn
|
||||
@@ -255,7 +245,6 @@ impl KigiStdioClient {
|
||||
resp.session_id
|
||||
}
|
||||
|
||||
/// Switch model on an existing session via the typed ACP `session/set_model`.
|
||||
pub async fn set_model(
|
||||
&self,
|
||||
session_id: &acp::SessionId,
|
||||
@@ -301,7 +290,6 @@ impl KigiStdioClient {
|
||||
self.home.take().expect("test home already taken")
|
||||
}
|
||||
|
||||
/// Return the home directory path (for cache invalidation between phases).
|
||||
pub fn home_path(&self) -> &std::path::Path {
|
||||
self.home.as_ref().expect("test home already taken").path()
|
||||
}
|
||||
@@ -401,7 +389,7 @@ impl KigiStdioClient {
|
||||
/// Exists for wire shapes the typed [`KigiStdioClient`] (`ClientSideConnection`,
|
||||
/// integer ids) can never produce — e.g. Xcode's Swift/Foundation `JSONEncoder`
|
||||
/// output: escaped-slash methods (`"session\/prompt"`) and string UUID request
|
||||
/// ids. Child process is killed on drop.
|
||||
/// ids. The child process is killed on drop.
|
||||
pub struct RawStdioClient {
|
||||
stdin: tokio::process::ChildStdin,
|
||||
stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
|
||||
@@ -431,7 +419,7 @@ impl RawStdioClient {
|
||||
String::from_utf8_lossy(&self.stderr.lock().unwrap()).into_owned()
|
||||
}
|
||||
|
||||
/// Write `line` verbatim followed by `\n`, and flush.
|
||||
/// Write `line` verbatim — no re-encoding — followed by `\n`, then flush.
|
||||
pub async fn send_line(&mut self, line: &str) {
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
|
||||
@@ -444,13 +432,13 @@ impl RawStdioClient {
|
||||
}
|
||||
|
||||
/// Read stdout lines until the response to `id` arrives (no `method` key +
|
||||
/// exact string-id match) — returning IS the id-echo assertion: an id
|
||||
/// echoed with different bytes or as a different JSON type never matches
|
||||
/// and surfaces in the timeout diagnostics instead. Notifications are
|
||||
/// skipped; any agent→client request is refused with a JSON-RPC error so a
|
||||
/// turn can never hang on this capability-less client. On timeout the
|
||||
/// panic reports how much non-matching traffic was seen (0 = true
|
||||
/// silence, the acp-0.6 escaped-method symptom) plus the last few lines.
|
||||
/// exact string-id match). Returning IS the id-echo assertion: an id echoed
|
||||
/// with different bytes or as a different JSON type never matches and
|
||||
/// surfaces in the timeout diagnostics instead. Any agent→client request is
|
||||
/// refused with a JSON-RPC error so a turn can never hang on this
|
||||
/// capability-less client. The timeout panic reports how much non-matching
|
||||
/// traffic was seen — 0 means true silence, the acp-0.6 escaped-method
|
||||
/// symptom.
|
||||
pub async fn response_for_id(
|
||||
&mut self,
|
||||
id: &str,
|
||||
@@ -504,7 +492,7 @@ impl RawStdioClient {
|
||||
}
|
||||
|
||||
/// Record a non-matching line for [`RawStdioClient::response_for_id`]'s timeout
|
||||
/// diagnostics: bump the count, keep the last 3 lines (truncated).
|
||||
/// diagnostics.
|
||||
fn push_skipped_tail(skipped: &mut usize, tail: &mut Vec<String>, line: &str) {
|
||||
*skipped += 1;
|
||||
if tail.len() == 3 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Minimal connection-counting HTTP/1.1 server for wire-level tests that need
|
||||
//! to assert TCP connection reuse (e.g. shared-client pooling): it counts
|
||||
//! accepted connections and records each request's header block.
|
||||
//! Minimal keep-alive HTTP/1.1 server for wire-level tests that assert TCP
|
||||
//! connection reuse (e.g. shared-client pooling): it counts accepted
|
||||
//! connections and records each request's header block.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -8,8 +8,7 @@ use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// Minimal keep-alive HTTP/1.1 server: counts accepted connections and
|
||||
/// records each request's header block.
|
||||
/// Returns the base URL, the accepted-connection count, and the header blocks.
|
||||
pub async fn spawn_counting_server() -> (String, Arc<AtomicUsize>, Arc<Mutex<Vec<String>>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}/v1", listener.local_addr().unwrap());
|
||||
|
||||
@@ -20,8 +20,7 @@ pub struct EnvGuard {
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
/// Set `key` to `value` for the guard's lifetime. Accepts `&str`, `&Path`,
|
||||
/// `String`, etc. via `AsRef<OsStr>`.
|
||||
/// Set `key` to `value` for the guard's lifetime.
|
||||
pub fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
|
||||
let prior = std::env::var_os(key);
|
||||
// SAFETY: callers are `#[serial]`, so no other thread touches the env.
|
||||
@@ -29,7 +28,6 @@ impl EnvGuard {
|
||||
Self { key, prior }
|
||||
}
|
||||
|
||||
/// Unset `key` for the guard's lifetime.
|
||||
pub fn unset(key: &'static str) -> Self {
|
||||
let prior = std::env::var_os(key);
|
||||
// SAFETY: see [`EnvGuard::set`].
|
||||
@@ -95,7 +93,8 @@ fn ensure_local_kigi_binary(binary: &Path) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve kigi binary: `KIGI_BINARY` env (CI) or a locally built `kigi-tui` binary.
|
||||
/// Resolve the kigi binary, building `kigi-tui` on demand if neither
|
||||
/// `KIGI_BINARY` (CI) nor cargo's own path points at an existing file.
|
||||
pub fn kigi_binary() -> PathBuf {
|
||||
if let Ok(path) = std::env::var("KIGI_BINARY") {
|
||||
let p = PathBuf::from(path);
|
||||
@@ -137,7 +136,7 @@ pub fn git_workdir() -> TempDir {
|
||||
}
|
||||
|
||||
run_git(&["init"], path);
|
||||
// Configure git user for commits (required in CI where no global config exists)
|
||||
// CI has no global git identity, so commits need one configured here.
|
||||
run_git(&["config", "user.email", "test@test.com"], path);
|
||||
run_git(&["config", "user.name", "Test"], path);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Headless mode (`kigi -p`) test runner.
|
||||
//!
|
||||
//! Runs the kigi binary as a subprocess with the mock server, captures output.
|
||||
//! Headless mode (`kigi -p`) test runner: runs the kigi binary as a subprocess
|
||||
//! against the mock server and captures its output.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitStatus;
|
||||
@@ -21,8 +20,8 @@ pub struct HeadlessResult {
|
||||
|
||||
const HEADLESS_TIMEOUT_SECS: u64 = 60;
|
||||
|
||||
/// Run `kigi` with the given args against the mock server, with a 60s timeout.
|
||||
/// Uses an isolated HOME and disables telemetry.
|
||||
/// Run `kigi` with the given args against the mock server in an isolated HOME,
|
||||
/// with telemetry disabled.
|
||||
pub async fn run_headless(
|
||||
server: &MockInferenceServer,
|
||||
args: &[&str],
|
||||
@@ -114,12 +113,10 @@ const CRASH_PATTERNS: &[&str] = &[
|
||||
"cannot open shared object",
|
||||
];
|
||||
|
||||
/// Diagnostic helper: format the tail of stderr for assertion messages.
|
||||
pub fn stderr_tail(stderr: &str, max_chars: usize) -> &str {
|
||||
&stderr[stderr.len().saturating_sub(max_chars)..]
|
||||
}
|
||||
|
||||
/// Assert that a headless run succeeded (non-timeout, zero exit code).
|
||||
pub fn assert_headless_success(
|
||||
result: &HeadlessResult,
|
||||
label: &str,
|
||||
@@ -141,7 +138,6 @@ pub fn assert_headless_success(
|
||||
);
|
||||
}
|
||||
|
||||
/// Panic if stderr contains any crash/linking-failure indicators.
|
||||
pub fn assert_no_crashes(stderr: &str) {
|
||||
let lower = stderr.to_lowercase();
|
||||
for pattern in CRASH_PATTERNS {
|
||||
|
||||
@@ -35,19 +35,14 @@ fn role_binary(env_key: &str) -> PathBuf {
|
||||
kigi_binary()
|
||||
}
|
||||
|
||||
/// Binary for the leader-electing side of a version-skew test
|
||||
/// (`KIGI_BINARY_LEADER`, else the shared [`kigi_binary`] resolution).
|
||||
pub fn leader_binary() -> PathBuf {
|
||||
role_binary(LEADER_BINARY_ENV)
|
||||
}
|
||||
|
||||
/// Binary for the client side of a version-skew test (`KIGI_BINARY_CLIENT`,
|
||||
/// else the shared [`kigi_binary`] resolution).
|
||||
pub fn client_binary() -> PathBuf {
|
||||
role_binary(CLIENT_BINARY_ENV)
|
||||
}
|
||||
|
||||
/// Capture for notifications + reconnect signals.
|
||||
#[derive(Default)]
|
||||
pub struct Capture {
|
||||
chunks: std::sync::Mutex<Vec<String>>,
|
||||
@@ -296,10 +291,11 @@ pub fn read_leader_pid(home: &Path) -> Option<u32> {
|
||||
}
|
||||
|
||||
pub fn pid_alive(pid: u32) -> bool {
|
||||
// SAFETY: `kill` with signal 0 delivers nothing and only probes whether the
|
||||
// process exists; every `pid` value is a valid argument.
|
||||
unsafe { libc::kill(pid as i32, 0) == 0 }
|
||||
}
|
||||
|
||||
/// Wait until the leader lock file contains a live PID, return it.
|
||||
pub async fn wait_for_live_leader(home: &Path, timeout: Duration) -> Option<u32> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
@@ -313,7 +309,6 @@ pub async fn wait_for_live_leader(home: &Path, timeout: Duration) -> Option<u32>
|
||||
None
|
||||
}
|
||||
|
||||
/// Wait until the leader lock file contains a live PID *different* from `old_pid`.
|
||||
pub async fn wait_for_new_leader(home: &Path, old_pid: u32, timeout: Duration) -> Option<u32> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
|
||||
@@ -6,19 +6,10 @@
|
||||
dead_code
|
||||
)]
|
||||
//! Shared test utilities for kigi crates: mock inference server, SSE
|
||||
//! generators, ACP stdio client, headless runner, env sandbox.
|
||||
//! generators, ACP stdio clients, headless runner, env sandbox.
|
||||
//!
|
||||
//! Provides:
|
||||
//! - [`MockInferenceServer`] — Mock /v1/chat/completions + /v1/responses with request logging
|
||||
//! - [`KigiStdioClient`] — ACP client that drives `kigi agent stdio` as a subprocess
|
||||
//! - [`RawStdioClient`] — raw-wire ACP driver for bytes the typed client can't
|
||||
//! produce (Foundation `\/` methods, string UUID ids)
|
||||
//! - [`leader::LeaderStdioClient`] — ACP client that drives `kigi agent --leader stdio` (unix)
|
||||
//! - [`run_headless`] — Run `kigi -p` against the mock server and capture output
|
||||
//! - [`git_workdir`] — Create a temp directory with git repo (forces libgit2 init)
|
||||
//! - [`kigi_binary`] — Resolve the kigi binary path (KIGI_BINARY env or cargo_bin)
|
||||
//! - [`spawn_counting_server`] — Connection-counting HTTP/1.1 server for wire/pooling tests
|
||||
//! - [`uds_proxy::UdsProxy`] — Frame-aware fault-injection proxy for leader IPC sockets (unix)
|
||||
//! [`RawStdioClient`] complements [`KigiStdioClient`] for wire bytes the typed
|
||||
//! client cannot produce (Foundation `\/` methods, string UUID ids).
|
||||
pub mod acp_client;
|
||||
pub mod counting_server;
|
||||
pub mod env;
|
||||
|
||||
@@ -35,7 +35,6 @@ pub struct LogEntry {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub body: Option<Value>,
|
||||
/// Value of the `Authorization` header, if present.
|
||||
pub authorization: Option<String>,
|
||||
/// Request headers (lowercase names, arrival order), captured on the
|
||||
/// inference POST endpoints; the GET endpoints log an empty list.
|
||||
@@ -90,13 +89,11 @@ type ScriptQueues = Arc<std::sync::Mutex<HashMap<String, VecDeque<ScriptedRespon
|
||||
/// A model entry for the mock `/v1/models` endpoint.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockModelEntry {
|
||||
/// Model ID (e.g. `"test-model"`).
|
||||
pub id: String,
|
||||
/// Optional agent type (e.g. `"cursor"`).
|
||||
/// Emitted as `agentType` inside `_meta` when set.
|
||||
/// Emitted as `agentType` inside `_meta` when set (e.g. `"cursor"`).
|
||||
pub agent_type: Option<String>,
|
||||
/// Optional API backend (e.g. `"messages"`). Emitted as `apiBackend`
|
||||
/// when set; absent means the shell's default backend.
|
||||
/// Emitted as `apiBackend` when set (e.g. `"messages"`); absent means the
|
||||
/// shell's default backend.
|
||||
pub api_backend: Option<String>,
|
||||
/// Emitted as `supportsBackendSearch` when true.
|
||||
pub supports_backend_search: bool,
|
||||
@@ -251,7 +248,6 @@ fn paced_events(
|
||||
if let Some(d) = delay {
|
||||
tokio::time::sleep(d).await;
|
||||
}
|
||||
// Hold the terminal event until the gate is released.
|
||||
if idx == last_idx
|
||||
&& let Some(gate) = gate.as_deref()
|
||||
{
|
||||
@@ -273,7 +269,6 @@ pub struct StorageUpload {
|
||||
pub size: usize,
|
||||
/// Request body when `size <= 256 KiB`; empty for larger payloads.
|
||||
pub body: Vec<u8>,
|
||||
/// `Authorization` header value as sent (e.g. `Bearer …`).
|
||||
pub authorization: Option<String>,
|
||||
}
|
||||
|
||||
@@ -384,7 +379,6 @@ impl MockInferenceServer {
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Wait for server readiness — try connecting instead of a fixed sleep.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||
while tokio::net::TcpStream::connect(addr).await.is_err() {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
@@ -546,7 +540,6 @@ impl MockInferenceServer {
|
||||
.any(|e| e.path.contains("responses"))
|
||||
}
|
||||
|
||||
/// Number of `POST /v1/messages` requests received so far.
|
||||
pub fn messages_request_count(&self) -> usize {
|
||||
self.log
|
||||
.entries
|
||||
@@ -557,7 +550,6 @@ impl MockInferenceServer {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Format the request log for diagnostic output on test failures.
|
||||
pub fn request_log_summary(&self) -> String {
|
||||
let entries = self.log.entries.lock().unwrap();
|
||||
if entries.is_empty() {
|
||||
@@ -571,7 +563,6 @@ impl MockInferenceServer {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Get the system prompt from the most recent inference request.
|
||||
pub fn last_system_prompt(&self) -> Option<String> {
|
||||
let entries = self.log.entries.lock().unwrap();
|
||||
entries
|
||||
@@ -579,15 +570,15 @@ impl MockInferenceServer {
|
||||
.rev()
|
||||
.find(|e| e.path.contains("chat/completions") || e.path.contains("responses"))
|
||||
.and_then(|e| e.body.as_ref())
|
||||
// Chat completions carries the system prompt as the first message;
|
||||
// the Responses API carries it in `instructions` instead.
|
||||
.and_then(|body| {
|
||||
// Chat completions format: messages[0].content (system message)
|
||||
body.get("messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.and_then(|msgs| msgs.first())
|
||||
.and_then(|msg| msg.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(String::from)
|
||||
// Responses API format: instructions field
|
||||
.or_else(|| {
|
||||
body.get("instructions")
|
||||
.and_then(|s| s.as_str())
|
||||
@@ -1115,7 +1106,6 @@ mod tests {
|
||||
const MERMAID_TEXT: &str =
|
||||
"Here is a flow:\n\n```mermaid\nflowchart TD\n A --> B\n```\n\nDone.\n";
|
||||
|
||||
/// Payloads of all `data:` lines in an SSE body, minus the `[DONE]` marker.
|
||||
fn sse_data_payloads(body: &str) -> Vec<String> {
|
||||
body.lines()
|
||||
.filter_map(|l| l.strip_prefix("data:"))
|
||||
@@ -1124,7 +1114,6 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Concatenation of all chat-completion content deltas in an SSE body.
|
||||
fn chat_stream_text(body: &str) -> String {
|
||||
sse_data_payloads(body)
|
||||
.iter()
|
||||
@@ -1140,7 +1129,6 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Concatenation of all responses-API output_text deltas in an SSE body.
|
||||
fn responses_stream_text(body: &str) -> String {
|
||||
sse_data_payloads(body)
|
||||
.iter()
|
||||
@@ -1150,7 +1138,6 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Concatenation of all Anthropic Messages text deltas in an SSE body.
|
||||
fn messages_stream_text(body: &str) -> String {
|
||||
sse_data_payloads(body)
|
||||
.iter()
|
||||
@@ -1184,7 +1171,7 @@ mod tests {
|
||||
let body = post_chat(&server, "ping pong").await.text().await.unwrap();
|
||||
assert_eq!(chat_stream_text(&body), "Echo: ping pong");
|
||||
|
||||
// Echo mode keeps its historical whitespace-collapsing semantics.
|
||||
// Echo mode collapses whitespace by design; only fixed mode is exact.
|
||||
let body = post_chat(&server, "a b\nc").await.text().await.unwrap();
|
||||
assert_eq!(chat_stream_text(&body), "Echo: a b c");
|
||||
}
|
||||
@@ -1337,8 +1324,8 @@ mod tests {
|
||||
assert_eq!(resp.status(), 401);
|
||||
}
|
||||
|
||||
/// Scripted response headers reach the client (the phase-2 script format's
|
||||
/// named consumer: 429 + Retry-After error injection).
|
||||
/// Scripted headers must reach the client: 429 + `Retry-After` injection
|
||||
/// depends on them.
|
||||
#[tokio::test]
|
||||
async fn scripted_response_headers_reach_the_client() {
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
|
||||
@@ -11,7 +11,6 @@ use axum::response::{IntoResponse, Response};
|
||||
use futures_util::stream;
|
||||
use serde_json::Value;
|
||||
|
||||
/// One SSE event as data: optional `event:` name plus the `data:` payload.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SseEvent {
|
||||
pub event: Option<String>,
|
||||
@@ -19,7 +18,6 @@ pub struct SseEvent {
|
||||
}
|
||||
|
||||
impl SseEvent {
|
||||
/// Event with a `data:` payload only.
|
||||
pub fn data(data: impl Into<String>) -> Self {
|
||||
Self {
|
||||
event: None,
|
||||
@@ -27,7 +25,6 @@ impl SseEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Event with an `event:` name and a `data:` payload.
|
||||
pub fn with_event(event: impl Into<String>, data: impl Into<String>) -> Self {
|
||||
Self {
|
||||
event: Some(event.into()),
|
||||
@@ -36,12 +33,11 @@ impl SseEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Body of a [`ScriptedResponse`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ScriptedBody {
|
||||
Json(Value),
|
||||
Sse(Vec<SseEvent>),
|
||||
/// Raw body bytes, served verbatim (byte-controllable malformed SSE etc.).
|
||||
/// Served verbatim, so the caller controls every byte (malformed SSE etc.).
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
@@ -56,7 +52,6 @@ pub struct ScriptedResponse {
|
||||
}
|
||||
|
||||
impl ScriptedResponse {
|
||||
/// 200 SSE response built from an event list.
|
||||
pub fn sse(events: Vec<SseEvent>) -> Self {
|
||||
Self {
|
||||
status: 200,
|
||||
@@ -65,7 +60,6 @@ impl ScriptedResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON body with the given status.
|
||||
pub fn json(status: u16, body: Value) -> Self {
|
||||
Self {
|
||||
status,
|
||||
@@ -74,7 +68,6 @@ impl ScriptedResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw text body with the given status.
|
||||
pub fn text(status: u16, body: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
@@ -93,10 +86,9 @@ impl ScriptedResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render to HTTP with SSE events paced by `delay` (sleep before each
|
||||
/// event, mirroring the fixed/echo `paced_events` pacing) so
|
||||
/// `set_chunk_delay` also holds scripted turns open. `None` streams
|
||||
/// instantly. Non-SSE bodies ignore the delay.
|
||||
/// SSE events are paced by sleeping `delay` before each one, mirroring the
|
||||
/// fixed/echo `paced_events` pacing so `set_chunk_delay` also holds
|
||||
/// scripted turns open. `None` streams instantly; non-SSE bodies ignore it.
|
||||
pub(crate) fn into_response_paced(self, delay: Option<std::time::Duration>) -> Response {
|
||||
use futures_util::StreamExt as _;
|
||||
let mut resp = match self.body {
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
//! SSE stream generators for mock inference endpoints.
|
||||
//!
|
||||
//! These produce the exact wire format that the kigi sampling client expects,
|
||||
//! validated against the real sampling client.
|
||||
//! SSE stream generators for mock inference endpoints, producing the exact
|
||||
//! wire format the real kigi sampling client parses.
|
||||
|
||||
use axum::response::sse::Event;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::scripted::SseEvent;
|
||||
|
||||
/// Generate Anthropic Messages SSE events: one text block streamed as a
|
||||
/// single delta, terminated by a `message_delta` carrying `stop_reason`.
|
||||
/// Anthropic Messages: one text block streamed as a single delta.
|
||||
pub fn messages_api_events(text: &str, model: &str, stop_reason: &str) -> Vec<Event> {
|
||||
vec![
|
||||
Event::default().data(
|
||||
@@ -43,30 +40,27 @@ pub fn messages_api_events(text: &str, model: &str, stop_reason: &str) -> Vec<Ev
|
||||
]
|
||||
}
|
||||
|
||||
/// Generate ChatCompletions SSE events that stream `text` word-by-word
|
||||
/// (whitespace-collapsing; use [`chat_completion_events_exact`] when the
|
||||
/// receiver must reconstruct `text` byte-for-byte).
|
||||
/// Streams `text` word-by-word, collapsing whitespace runs; use
|
||||
/// [`chat_completion_events_exact`] when the receiver must reconstruct `text`
|
||||
/// byte-for-byte.
|
||||
pub fn chat_completion_events(text: &str, model: &str) -> Vec<Event> {
|
||||
chat_completion_events_from_deltas(&space_prefixed_deltas(text.split_whitespace()), model)
|
||||
}
|
||||
|
||||
/// Like [`chat_completion_events`] but byte-exact: concatenating the deltas
|
||||
/// reproduces `text` byte-for-byte (newlines and whitespace runs preserved).
|
||||
/// Fenced code blocks (mermaid etc.) need their newlines to parse as a block,
|
||||
/// which `split_whitespace` would destroy.
|
||||
/// reproduces `text` byte-for-byte. Fenced code blocks (mermaid etc.) need
|
||||
/// their newlines to parse as a block, which `split_whitespace` destroys.
|
||||
pub fn chat_completion_events_exact(text: &str, model: &str) -> Vec<Event> {
|
||||
chat_completion_events_from_deltas(&chat_completion_deltas(text), model)
|
||||
}
|
||||
|
||||
/// Split `text` into deltas that reconstruct it byte-for-byte: the first
|
||||
/// carries no leading space; each subsequent one is ` {word}` (split on
|
||||
/// single spaces only, so newlines/tabs stay inside the words).
|
||||
/// Splits on single spaces only, so newlines and tabs stay inside the words
|
||||
/// and concatenating the deltas reconstructs `text` byte-for-byte.
|
||||
fn chat_completion_deltas(text: &str) -> Vec<String> {
|
||||
space_prefixed_deltas(text.split(' '))
|
||||
}
|
||||
|
||||
/// Shape words into chat deltas: first word bare, each subsequent one ` {word}`
|
||||
/// — the source iterator decides collapsing (echo) vs byte-exact (fixed).
|
||||
/// The caller's iterator decides collapsing (echo) vs byte-exact (fixed).
|
||||
fn space_prefixed_deltas<'a>(words: impl Iterator<Item = &'a str>) -> Vec<String> {
|
||||
words
|
||||
.enumerate()
|
||||
@@ -119,7 +113,6 @@ fn chat_completion_events_from_deltas(deltas: &[String], model: &str) -> Vec<Eve
|
||||
events.push(Event::default().data(chunk.to_string()));
|
||||
}
|
||||
|
||||
// Usage chunk
|
||||
events.push(
|
||||
Event::default().data(
|
||||
json!({
|
||||
@@ -141,9 +134,9 @@ fn chat_completion_events_from_deltas(deltas: &[String], model: &str) -> Vec<Eve
|
||||
events
|
||||
}
|
||||
|
||||
/// Generate Responses API SSE events that stream `text` word-by-word
|
||||
/// (whitespace-collapsing; use [`responses_api_events_exact`] when the
|
||||
/// receiver must reconstruct `text` byte-for-byte).
|
||||
/// Streams `text` word-by-word, collapsing whitespace runs; use
|
||||
/// [`responses_api_events_exact`] when the receiver must reconstruct `text`
|
||||
/// byte-for-byte.
|
||||
pub fn responses_api_events(text: &str, model: &str) -> Vec<Event> {
|
||||
let deltas: Vec<String> = text
|
||||
.split_whitespace()
|
||||
@@ -153,7 +146,7 @@ pub fn responses_api_events(text: &str, model: &str) -> Vec<Event> {
|
||||
}
|
||||
|
||||
/// Like [`responses_api_events`] but byte-exact: concatenating the deltas
|
||||
/// reproduces `text` byte-for-byte (newlines and whitespace runs preserved).
|
||||
/// reproduces `text` byte-for-byte.
|
||||
pub fn responses_api_events_exact(text: &str, model: &str) -> Vec<Event> {
|
||||
responses_api_events_from_deltas(&responses_api_deltas(text), text, model)
|
||||
}
|
||||
@@ -170,7 +163,6 @@ fn responses_api_events_from_deltas(deltas: &[String], text: &str, model: &str)
|
||||
let mut events = Vec::new();
|
||||
let mut seq = 0;
|
||||
|
||||
// response.created
|
||||
events.push(
|
||||
Event::default().data(
|
||||
json!({
|
||||
@@ -190,7 +182,6 @@ fn responses_api_events_from_deltas(deltas: &[String], text: &str, model: &str)
|
||||
);
|
||||
seq += 1;
|
||||
|
||||
// Text deltas
|
||||
for chunk in deltas {
|
||||
events.push(
|
||||
Event::default().data(
|
||||
@@ -208,7 +199,6 @@ fn responses_api_events_from_deltas(deltas: &[String], text: &str, model: &str)
|
||||
seq += 1;
|
||||
}
|
||||
|
||||
// response.completed
|
||||
events.push(
|
||||
Event::default().data(
|
||||
json!({
|
||||
@@ -247,22 +237,19 @@ fn responses_api_events_from_deltas(deltas: &[String], text: &str, model: &str)
|
||||
events
|
||||
}
|
||||
|
||||
/// Generate Responses API SSE events for a reasoning-only completion: the
|
||||
/// model streams reasoning summary deltas and finishes with a `reasoning`
|
||||
/// output item but NO message / output-text and no tool call. The shell's
|
||||
/// collector synthesizes an empty assistant, so the response classifies as
|
||||
/// `EmptyReason::ReasoningOnly` — the trigger that makes the sampler resample
|
||||
/// (the model doomloop).
|
||||
/// Reasoning-only completion: reasoning summary deltas and a `reasoning`
|
||||
/// output item, with no message, output text or tool call.
|
||||
/// `response_to_conversation_items` appends an empty assistant, yielding
|
||||
/// `[Reasoning, Assistant("")]`, so the turn classifies as
|
||||
/// `EmptyReason::ReasoningOnly` and the sampler resamples (the model doomloop).
|
||||
///
|
||||
/// Returns [`SseEvent`]s (not axum `Event`s) for direct use with
|
||||
/// [`crate::ScriptedResponse::sse`] / `enqueue_response`: reasoning-only is a
|
||||
/// scripted scenario, not an echo/fixed response mode, so it is not wired into
|
||||
/// the `mock_server` mode handlers.
|
||||
/// Returns [`SseEvent`]s rather than axum `Event`s because this is a scripted
|
||||
/// scenario ([`crate::ScriptedResponse::sse`] / `enqueue_response`), not an
|
||||
/// echo/fixed response mode wired into the `mock_server` handlers.
|
||||
pub fn responses_api_reasoning_only_events(reasoning: &str, model: &str) -> Vec<SseEvent> {
|
||||
let mut events = Vec::new();
|
||||
let mut seq = 0;
|
||||
|
||||
// response.created
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
"type": "response.created",
|
||||
@@ -280,7 +267,6 @@ pub fn responses_api_reasoning_only_events(reasoning: &str, model: &str) -> Vec<
|
||||
));
|
||||
seq += 1;
|
||||
|
||||
// Reasoning summary deltas — the only content the model streams.
|
||||
for word in reasoning.split_whitespace() {
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
@@ -296,9 +282,6 @@ pub fn responses_api_reasoning_only_events(reasoning: &str, model: &str) -> Vec<
|
||||
seq += 1;
|
||||
}
|
||||
|
||||
// response.completed: a single `reasoning` output item carrying the full
|
||||
// summary and NO message item. `response_to_conversation_items` appends an
|
||||
// empty assistant, yielding `[Reasoning, Assistant("")]` → reasoning_only.
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
@@ -333,14 +316,10 @@ pub fn responses_api_reasoning_only_events(reasoning: &str, model: &str) -> Vec<
|
||||
events
|
||||
}
|
||||
|
||||
/// Generate Responses API SSE events for a completion that streams reasoning
|
||||
/// summary deltas FIRST and then a normal text answer: the shape a
|
||||
/// Reasoning summary deltas first, then a normal text answer: the shape a
|
||||
/// reasoning-capable model produces on an ordinary turn. `response.completed`
|
||||
/// carries both output items (`reasoning` + `message`), so the collector
|
||||
/// yields `[Reasoning, Assistant(text)]` — a full, non-empty turn.
|
||||
///
|
||||
/// Returns [`SseEvent`]s for direct use with [`crate::ScriptedResponse::sse`]
|
||||
/// / `enqueue_response`, mirroring [`responses_api_reasoning_only_events`].
|
||||
/// carries both output items, so the collector yields
|
||||
/// `[Reasoning, Assistant(text)]` — a full, non-empty turn.
|
||||
pub fn responses_api_reasoning_and_text_events(
|
||||
reasoning: &str,
|
||||
text: &str,
|
||||
@@ -349,7 +328,6 @@ pub fn responses_api_reasoning_and_text_events(
|
||||
let mut events = Vec::new();
|
||||
let mut seq = 0;
|
||||
|
||||
// response.created
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
"type": "response.created",
|
||||
@@ -367,7 +345,6 @@ pub fn responses_api_reasoning_and_text_events(
|
||||
));
|
||||
seq += 1;
|
||||
|
||||
// Reasoning summary deltas stream before any answer text.
|
||||
for word in reasoning.split_whitespace() {
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
@@ -383,7 +360,6 @@ pub fn responses_api_reasoning_and_text_events(
|
||||
seq += 1;
|
||||
}
|
||||
|
||||
// Then the visible answer.
|
||||
for word in text.split_whitespace() {
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
@@ -399,7 +375,6 @@ pub fn responses_api_reasoning_and_text_events(
|
||||
seq += 1;
|
||||
}
|
||||
|
||||
// response.completed with BOTH items: reasoning + the assistant message.
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
@@ -448,14 +423,12 @@ pub fn responses_api_reasoning_and_text_events(
|
||||
}
|
||||
|
||||
/// SSE `event:` name and payload `type` of the non-standard doom-loop check
|
||||
/// event (`kigi_sampling_types::DOOM_LOOP_CHECK_EVENT_TYPE`). Hardcoded
|
||||
/// like every other wire string in this file; the shell integration tests
|
||||
/// pin the two spellings against each other by absorbing built frames
|
||||
/// through the real client.
|
||||
/// event, duplicating `kigi_sampling_types::DOOM_LOOP_CHECK_EVENT_TYPE` like
|
||||
/// every other wire string here; the shell integration tests pin the two
|
||||
/// spellings against each other by absorbing built frames through the real
|
||||
/// client.
|
||||
const DOOM_LOOP_CHECK_EVENT: &str = "response.doom_loop_check";
|
||||
|
||||
/// One named `response.doom_loop_check` frame carrying the (cumulative)
|
||||
/// trigger set, in the inference API's wire shape.
|
||||
fn doom_loop_check_frame(triggers: &[&str], seq: u64) -> SseEvent {
|
||||
SseEvent::with_event(
|
||||
DOOM_LOOP_CHECK_EVENT,
|
||||
@@ -469,11 +442,11 @@ fn doom_loop_check_frame(triggers: &[&str], seq: u64) -> SseEvent {
|
||||
}
|
||||
|
||||
/// Inject `doom_loop_check.triggers` into a turn's terminal
|
||||
/// `response.completed` object — the single home for terminal-field emission,
|
||||
/// the dual of the mid-stream [`doom_loop_check_frame`]. Composes over any
|
||||
/// turn builder (re-serialization may reorder JSON keys; clients and shape
|
||||
/// tests parse, never byte-compare, these frames). Panics when the turn has
|
||||
/// no completed frame: every builder emits one, so a miss is a script bug.
|
||||
/// `response.completed` object — the dual of the mid-stream
|
||||
/// [`doom_loop_check_frame`]. Composes over any turn builder; re-serialization
|
||||
/// may reorder JSON keys, which is safe because clients and shape tests parse
|
||||
/// these frames rather than byte-compare them. Every builder emits a completed
|
||||
/// frame, so a miss is a script bug.
|
||||
fn with_terminal_doom_loop_field(mut events: Vec<SseEvent>, triggers: &[&str]) -> Vec<SseEvent> {
|
||||
let patched = events.iter_mut().any(|e| {
|
||||
if e.data == "[DONE]" {
|
||||
@@ -496,16 +469,12 @@ fn with_terminal_doom_loop_field(mut events: Vec<SseEvent>, triggers: &[&str]) -
|
||||
events
|
||||
}
|
||||
|
||||
/// Generate Responses API SSE events for a server-detected doom loop: a
|
||||
/// reasoning-only stream (the doomed signature — the model loops in its
|
||||
/// thinking and never answers) followed by named `response.doom_loop_check`
|
||||
/// frames re-sent with the growing **cumulative** trigger set (one frame per
|
||||
/// prefix of `triggers`, mirroring how the server re-emits as new triggers
|
||||
/// appear), and a terminal `response.completed` whose response object carries
|
||||
/// the full set under `doom_loop_check.triggers`.
|
||||
///
|
||||
/// Returns [`SseEvent`]s for direct use with [`crate::ScriptedResponse::sse`]
|
||||
/// / `enqueue_response`, mirroring [`responses_api_reasoning_only_events`].
|
||||
/// Server-detected doom loop: a reasoning-only stream (the doomed signature —
|
||||
/// the model loops in its thinking and never answers) followed by named
|
||||
/// `response.doom_loop_check` frames carrying the growing **cumulative**
|
||||
/// trigger set (one frame per prefix of `triggers`, mirroring how the server
|
||||
/// re-emits as new triggers appear), and a terminal `response.completed` whose
|
||||
/// response object carries the full set under `doom_loop_check.triggers`.
|
||||
pub fn responses_api_doom_loop_check_events(
|
||||
triggers: &[&str],
|
||||
reasoning: &str,
|
||||
@@ -524,10 +493,9 @@ pub fn responses_api_doom_loop_check_events(
|
||||
with_terminal_doom_loop_field(events, triggers)
|
||||
}
|
||||
|
||||
/// Generate Responses API SSE events for an ordinary reasoning + text turn
|
||||
/// (mirroring [`responses_api_reasoning_and_text_events`]) whose terminal
|
||||
/// `response.completed` object carries `doom_loop_check.triggers` with NO
|
||||
/// mid-stream check frame — the terminal-only copy of the signal.
|
||||
/// An ordinary reasoning + text turn whose terminal `response.completed`
|
||||
/// object carries `doom_loop_check.triggers` with NO mid-stream check frame —
|
||||
/// the terminal-only copy of the signal.
|
||||
pub fn responses_api_doom_loop_terminal_only_events(
|
||||
triggers: &[&str],
|
||||
reasoning: &str,
|
||||
@@ -543,8 +511,8 @@ pub fn responses_api_doom_loop_terminal_only_events(
|
||||
/// Splice ONE named `response.doom_loop_check` frame with an arbitrary
|
||||
/// `data:` payload — a byte-exact wire fixture or a malformed variant — into
|
||||
/// an otherwise-normal reasoning + text turn, right after `response.created`.
|
||||
/// The payload's own `sequence_number` (if any) is its business; clients
|
||||
/// never validate sequence continuity.
|
||||
/// The payload's own `sequence_number` (if any) is its business: clients never
|
||||
/// validate sequence continuity.
|
||||
pub fn responses_api_with_doom_loop_frame(
|
||||
check_frame_data: &str,
|
||||
reasoning: &str,
|
||||
@@ -559,16 +527,11 @@ pub fn responses_api_with_doom_loop_frame(
|
||||
events
|
||||
}
|
||||
|
||||
/// Generate Responses API SSE events for a turn that streams reasoning
|
||||
/// summary deltas FIRST and then issues one `function_call` — the shape a
|
||||
/// reasoning-capable model produces when it thinks before its first tool
|
||||
/// call. `response.completed` carries both output items (`reasoning` +
|
||||
/// `function_call`) and no message, so the collector yields
|
||||
/// `[Reasoning, ToolCall]` (tool calls keep the turn non-empty — no
|
||||
/// `EmptyReason::ReasoningOnly` resample).
|
||||
///
|
||||
/// Returns [`SseEvent`]s for direct use with [`crate::ScriptedResponse::sse`]
|
||||
/// / `enqueue_response`, mirroring [`responses_api_reasoning_only_events`].
|
||||
/// Reasoning summary deltas first, then one `function_call` — the shape a
|
||||
/// reasoning-capable model produces when it thinks before its first tool call.
|
||||
/// `response.completed` carries both output items and no message, so the
|
||||
/// collector yields `[Reasoning, ToolCall]`; the tool call keeps the turn
|
||||
/// non-empty, so there is no `EmptyReason::ReasoningOnly` resample.
|
||||
pub fn responses_api_reasoning_then_tool_call_events(
|
||||
reasoning: &str,
|
||||
call_id: &str,
|
||||
@@ -579,7 +542,6 @@ pub fn responses_api_reasoning_then_tool_call_events(
|
||||
let mut events = Vec::new();
|
||||
let mut seq = 0;
|
||||
|
||||
// response.created
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
"type": "response.created",
|
||||
@@ -597,7 +559,6 @@ pub fn responses_api_reasoning_then_tool_call_events(
|
||||
));
|
||||
seq += 1;
|
||||
|
||||
// Reasoning summary deltas stream before the tool call.
|
||||
for word in reasoning.split_whitespace() {
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
@@ -613,7 +574,6 @@ pub fn responses_api_reasoning_then_tool_call_events(
|
||||
seq += 1;
|
||||
}
|
||||
|
||||
// Then the tool invocation.
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
@@ -626,7 +586,6 @@ pub fn responses_api_reasoning_then_tool_call_events(
|
||||
));
|
||||
seq += 1;
|
||||
|
||||
// response.completed: the reasoning item plus the function_call item.
|
||||
events.push(SseEvent::data(
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
@@ -746,11 +705,9 @@ pub fn chat_completions_reasoning_then_tool_call_events(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Both byte-exact delta encoders must reconstruct a multi-line response
|
||||
/// (incl. a ```mermaid fence) byte-for-byte. This is load-bearing:
|
||||
/// `split_whitespace` would collapse the fence's newlines onto one line,
|
||||
/// so a client would never parse it as a code block and diagram detection
|
||||
/// would silently fail.
|
||||
/// Load-bearing: `split_whitespace` would collapse the fence's newlines
|
||||
/// onto one line, so a client would never parse it as a code block and
|
||||
/// diagram detection would silently fail.
|
||||
#[test]
|
||||
fn deltas_reconstruct_multiline_response_byte_for_byte() {
|
||||
let text = "Here is a flow:\n\n```mermaid\nflowchart TD\n A --> B\n B --> C\n```\n\nDone rendering.\n";
|
||||
@@ -758,8 +715,6 @@ mod tests {
|
||||
assert_eq!(chat_completion_deltas(text).concat(), text);
|
||||
assert_eq!(responses_api_deltas(text).concat(), text);
|
||||
|
||||
// The reconstruction preserves the fence as a real, newline-delimited
|
||||
// code block (the property diagram detection depends on).
|
||||
assert!(
|
||||
chat_completion_deltas(text)
|
||||
.concat()
|
||||
@@ -767,8 +722,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Multiple consecutive spaces and a trailing newline survive too (no
|
||||
/// `split_whitespace`-style collapsing).
|
||||
#[test]
|
||||
fn deltas_preserve_runs_of_whitespace() {
|
||||
let text = "a b\tc\n";
|
||||
@@ -776,21 +729,16 @@ mod tests {
|
||||
assert_eq!(responses_api_deltas(text).concat(), text);
|
||||
}
|
||||
|
||||
/// Shape guard for the reasoning-only builder: parse each event back to JSON
|
||||
/// and assert the structural tags/fields the shell collector keys on — at
|
||||
/// least one `response.reasoning_summary_text.delta` carrying text, no
|
||||
/// `response.output_text.delta`, and a `response.completed` whose output
|
||||
/// holds a `reasoning` item (with summary text) and no `message` item,
|
||||
/// terminated by `[DONE]`. A full round-trip through `rs::ResponseStreamEvent`
|
||||
/// would pin the async-openai types directly, but that crate is not a
|
||||
/// dependency here; the integration test deserializes these events through
|
||||
/// the real client, covering the wire contract end-to-end.
|
||||
/// Structural shape guard only: a round-trip through
|
||||
/// `rs::ResponseStreamEvent` would pin the async-openai types directly,
|
||||
/// but that crate is not a dependency here. The shell integration test
|
||||
/// deserializes these events through the real client, covering the wire
|
||||
/// contract end-to-end.
|
||||
#[test]
|
||||
fn reasoning_only_events_carry_reasoning_and_no_output_text() {
|
||||
let events = responses_api_reasoning_only_events("alpha beta gamma", "m");
|
||||
assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));
|
||||
|
||||
// Parse every non-terminal event into JSON and key off the `type` tag.
|
||||
let parsed: Vec<serde_json::Value> = events
|
||||
.iter()
|
||||
.filter(|e| e.data != "[DONE]")
|
||||
@@ -841,10 +789,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shape guard for the reasoning+text builder: reasoning summary deltas
|
||||
/// stream before the output-text deltas, and `response.completed` carries
|
||||
/// BOTH a `reasoning` item and a `message` item, terminated by `[DONE]` —
|
||||
/// the ordinary reasoning-model turn (never `EmptyReason::ReasoningOnly`).
|
||||
#[test]
|
||||
fn reasoning_and_text_events_carry_both_items() {
|
||||
let events = responses_api_reasoning_and_text_events("alpha beta", "the answer", "m");
|
||||
@@ -860,7 +804,6 @@ mod tests {
|
||||
.map(|v| v["type"].as_str().expect("each event has a type tag"))
|
||||
.collect();
|
||||
|
||||
// Reasoning streams strictly before the visible answer.
|
||||
let first_reasoning = types
|
||||
.iter()
|
||||
.position(|t| *t == "response.reasoning_summary_text.delta")
|
||||
@@ -893,11 +836,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shape guard for the reasoning+tool-call builder: reasoning summary
|
||||
/// deltas stream before the function-call args delta, no output text
|
||||
/// anywhere, and `response.completed` carries a `reasoning` item plus a
|
||||
/// `function_call` item (no `message`), terminated by `[DONE]` — the
|
||||
/// think-then-call turn whose tool call keeps it non-empty.
|
||||
#[test]
|
||||
fn reasoning_then_tool_call_events_carry_reasoning_and_function_call() {
|
||||
let events = responses_api_reasoning_then_tool_call_events(
|
||||
@@ -919,7 +857,6 @@ mod tests {
|
||||
.map(|v| v["type"].as_str().expect("each event has a type tag"))
|
||||
.collect();
|
||||
|
||||
// Reasoning streams strictly before the tool invocation; no text.
|
||||
let first_reasoning = types
|
||||
.iter()
|
||||
.position(|t| *t == "response.reasoning_summary_text.delta")
|
||||
@@ -958,10 +895,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shape guard for the Chat Completions twin: `reasoning_content` deltas
|
||||
/// stream first, then exactly one `tool_calls` delta carrying the call
|
||||
/// id/name/arguments, then a `finish_reason: "tool_calls"` chunk, with no
|
||||
/// visible `content` anywhere, terminated by `[DONE]`.
|
||||
#[test]
|
||||
fn chat_reasoning_then_tool_call_events_carry_reasoning_then_tool_call() {
|
||||
let events = chat_completions_reasoning_then_tool_call_events(
|
||||
@@ -1010,10 +943,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shape guard for the doom-loop builder: one NAMED check frame per
|
||||
/// cumulative prefix of `triggers` (each frame re-sends every trigger so
|
||||
/// far), a reasoning-only output (no message item — the doomed
|
||||
/// signature), and the terminal response object carrying the full set.
|
||||
#[test]
|
||||
fn doom_loop_check_events_send_growing_named_frames_and_terminal_field() {
|
||||
let events = responses_api_doom_loop_check_events(
|
||||
@@ -1058,9 +987,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shape guard for the terminal-only variant: no named check frame
|
||||
/// anywhere; the completed response carries both output items (the turn
|
||||
/// is a normal answer) plus `doom_loop_check.triggers`.
|
||||
#[test]
|
||||
fn doom_loop_terminal_only_events_carry_field_without_mid_stream_frame() {
|
||||
let events = responses_api_doom_loop_terminal_only_events(
|
||||
@@ -1089,9 +1015,6 @@ mod tests {
|
||||
assert!(output.iter().any(|o| o["type"] == "reasoning"));
|
||||
}
|
||||
|
||||
/// Shape guard for the splice helper: the named frame lands right after
|
||||
/// `response.created` with the caller's payload byte-for-byte (this is
|
||||
/// how byte-exact fixtures and malformed variants ride a normal turn).
|
||||
#[test]
|
||||
fn with_doom_loop_frame_splices_payload_verbatim() {
|
||||
let payload = r#"{"type":"response.doom_loop_check","doom_loop_check":{"triggers":42}}"#;
|
||||
|
||||
@@ -21,7 +21,6 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Which pump direction a [`FaultPlan`] applies to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum FaultDirection {
|
||||
#[default]
|
||||
@@ -99,8 +98,6 @@ pub struct UdsProxy {
|
||||
}
|
||||
|
||||
impl UdsProxy {
|
||||
/// Bind `proxy_path` and forward each accepted connection to
|
||||
/// `upstream_path`, applying `plan` per connection.
|
||||
pub async fn spawn(
|
||||
proxy_path: impl Into<PathBuf>,
|
||||
upstream_path: impl AsRef<Path>,
|
||||
@@ -144,7 +141,6 @@ impl UdsProxy {
|
||||
self.handle.clone()
|
||||
}
|
||||
|
||||
/// Stop accepting and sever active connections.
|
||||
pub fn shutdown(&self) {
|
||||
self.handle.sever_now();
|
||||
self.cancel.cancel();
|
||||
@@ -327,7 +323,6 @@ mod tests {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Upstream that echoes every frame back to the sender.
|
||||
fn spawn_echo_upstream(path: PathBuf) {
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
tokio::spawn(async move {
|
||||
@@ -397,7 +392,6 @@ mod tests {
|
||||
client_write_frame(&mut client, b"second").await;
|
||||
client_write_frame(&mut client, b"third").await;
|
||||
|
||||
// The echo of "second" never arrives; "third" comes straight after "first".
|
||||
assert_eq!(client_read_frame(&mut client).await.unwrap(), b"first");
|
||||
assert_eq!(client_read_frame(&mut client).await.unwrap(), b"third");
|
||||
}
|
||||
@@ -444,7 +438,7 @@ mod tests {
|
||||
client_write_frame(&mut client, b"never-delivered").await;
|
||||
|
||||
// The upstream got 2 bytes of a length prefix and then a close, so it
|
||||
// echoes nothing; the client's next read observes the sever.
|
||||
// echoes nothing and the client's next read observes the sever.
|
||||
let read = client_read_frame(&mut client).await;
|
||||
assert!(
|
||||
read.is_err(),
|
||||
|
||||
Reference in New Issue
Block a user