test(e2e): deep chat-harness coverage + streaming mock LLM + rust-e2e Linux lane (#1892)

This commit is contained in:
Steven Enamakel
2026-05-15 23:28:56 -07:00
committed by GitHub
parent f90a337bc0
commit dbbd33ea2c
14 changed files with 1940 additions and 47 deletions
+13
View File
@@ -575,6 +575,19 @@ pub async fn invalidate_thread_sessions(thread_id: &str) {
}
}
/// Snapshot the IN_FLIGHT map for the test-support introspection RPC.
///
/// Returned as `(map_key, request_id)` pairs. Not intended for any
/// production caller — release builds reach this via the bearer-gated
/// `/rpc` endpoint only, and the per-launch token file is debug-only.
pub async fn in_flight_entries_for_test() -> Vec<(String, String)> {
let guard = IN_FLIGHT.lock().await;
guard
.iter()
.map(|(k, v)| (k.clone(), v.request_id.clone()))
.collect()
}
pub async fn cancel_chat(client_id: &str, thread_id: &str) -> Result<Option<String>, String> {
let client_id = client_id.trim();
let thread_id = thread_id.trim();
+265
View File
@@ -0,0 +1,265 @@
//! Read-only introspection RPCs for E2E specs.
//!
//! These mirror the access patterns specs need to verify that "the UI did
//! something" actually flowed all the way through to disk + the in-process
//! Rust state. They are intentionally narrow and read-only — no writes,
//! no side effects beyond a single config/file read.
//!
//! Like `test_reset`, the bearer-token requirement on `/rpc` keeps these
//! out of release-build reach (the per-launch token file is only written
//! in debug builds).
use std::path::{Path, PathBuf};
use serde::Serialize;
use tokio::fs;
use crate::openhuman::channels::providers::web::in_flight_entries_for_test;
use crate::openhuman::config::Config;
use crate::rpc::RpcOutcome;
/// Maximum bytes returned by `read_workspace_file`. Specs that need bigger
/// reads should chunk through `list_workspace_files` + multiple reads.
const READ_FILE_MAX_BYTES: u64 = 1024 * 1024; // 1 MiB
/// Maximum recursion depth for `list_workspace_files`.
const LIST_MAX_DEPTH: u32 = 6;
/// Reject any relative path containing a `..` component or that resolves
/// outside the workspace root. Returns the joined absolute path on success.
fn resolve_workspace_relative(workspace: &Path, rel: &str) -> Result<PathBuf, String> {
let trimmed = rel.trim_start_matches('/');
let candidate = workspace.join(trimmed);
let canonical_root = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
let canonical_candidate = candidate
.canonicalize()
.unwrap_or_else(|_| candidate.clone());
if !canonical_candidate.starts_with(&canonical_root) {
return Err(format!(
"rel_path {rel:?} escapes workspace root {}",
workspace.display()
));
}
Ok(candidate)
}
async fn current_workspace_dir() -> Result<PathBuf, String> {
let config = Config::load_or_init()
.await
.map_err(|e| format!("load config: {e}"))?;
Ok(config.workspace_dir.clone())
}
// ── workspace_root ────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct WorkspaceRoot {
pub path: String,
pub exists: bool,
}
pub async fn workspace_root() -> Result<RpcOutcome<WorkspaceRoot>, String> {
let dir = current_workspace_dir().await?;
let exists = fs::try_exists(&dir).await.unwrap_or(false);
Ok(RpcOutcome::single_log(
WorkspaceRoot {
path: dir.display().to_string(),
exists,
},
format!("workspace_root: {} (exists={exists})", dir.display()),
))
}
// ── list_workspace_files ──────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct ListEntry {
pub rel_path: String,
pub size: u64,
pub is_dir: bool,
}
#[derive(Debug, Serialize)]
pub struct ListResult {
pub root: String,
pub entries: Vec<ListEntry>,
pub truncated: bool,
}
async fn walk_dir(
root: &Path,
start: &Path,
max_depth: u32,
out: &mut Vec<ListEntry>,
limit: usize,
) -> Result<bool, String> {
// Explicit BFS stack avoids needing the async_recursion crate.
let mut stack: Vec<(PathBuf, u32)> = vec![(start.to_path_buf(), 0)];
while let Some((cur, depth)) = stack.pop() {
if depth > max_depth {
continue;
}
let mut rd = match fs::read_dir(&cur).await {
Ok(rd) => rd,
Err(_) => continue,
};
while let Some(entry) = rd
.next_entry()
.await
.map_err(|e| format!("read_dir: {e}"))?
{
if out.len() >= limit {
return Ok(true);
}
let path = entry.path();
let rel = path
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string());
// `symlink_metadata` does NOT follow links — a symlink
// inside the workspace that points outside (or anywhere
// else) would otherwise be reported, and if it resolved to
// a directory we'd recurse into it. That would break the
// workspace-only guarantee the RPC promises.
let meta = match fs::symlink_metadata(&path).await {
Ok(m) => m,
Err(_) => continue,
};
if meta.file_type().is_symlink() {
continue;
}
let is_dir = meta.is_dir();
out.push(ListEntry {
rel_path: rel,
size: if is_dir { 0 } else { meta.len() },
is_dir,
});
if is_dir && depth + 1 <= max_depth {
stack.push((path, depth + 1));
}
}
}
Ok(false)
}
pub async fn list_workspace_files(
rel_root: Option<String>,
max_depth: Option<u32>,
) -> Result<RpcOutcome<ListResult>, String> {
let workspace = current_workspace_dir().await?;
let root = match rel_root.as_deref().filter(|s| !s.is_empty()) {
Some(r) => resolve_workspace_relative(&workspace, r)?,
None => workspace.clone(),
};
let depth = max_depth.unwrap_or(2).min(LIST_MAX_DEPTH);
let mut entries = Vec::new();
let truncated = walk_dir(&root, &root, depth, &mut entries, 2_000).await?;
Ok(RpcOutcome::single_log(
ListResult {
root: root.display().to_string(),
entries: entries.iter().cloned().collect(),
truncated,
},
format!(
"listed {} entries (depth={depth}, truncated={truncated}) under {}",
entries.len(),
root.display()
),
))
}
// `ListEntry` is `Clone`-able for the log line summarisation above.
impl Clone for ListEntry {
fn clone(&self) -> Self {
Self {
rel_path: self.rel_path.clone(),
size: self.size,
is_dir: self.is_dir,
}
}
}
// ── read_workspace_file ───────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct ReadFileResult {
pub rel_path: String,
pub size_on_disk: u64,
pub returned_bytes: u64,
pub truncated: bool,
pub content_utf8: String,
}
pub async fn read_workspace_file(
rel_path: String,
max_bytes: Option<u64>,
) -> Result<RpcOutcome<ReadFileResult>, String> {
let workspace = current_workspace_dir().await?;
let abs = resolve_workspace_relative(&workspace, &rel_path)?;
let meta = fs::metadata(&abs)
.await
.map_err(|e| format!("stat {}: {e}", abs.display()))?;
if meta.is_dir() {
return Err(format!("read_workspace_file: {rel_path} is a directory"));
}
let cap = max_bytes
.unwrap_or(READ_FILE_MAX_BYTES)
.min(READ_FILE_MAX_BYTES);
let raw = fs::read(&abs)
.await
.map_err(|e| format!("read {}: {e}", abs.display()))?;
let size_on_disk = raw.len() as u64;
let truncated = size_on_disk > cap;
let returned = if truncated {
raw[..cap as usize].to_vec()
} else {
raw
};
// `returned_bytes` is the raw byte count read from disk before
// lossy UTF-8 conversion — `from_utf8_lossy` substitutes U+FFFD
// for invalid sequences, which can change the byte length. Specs
// assert against this value to verify byte-accurate truncation.
let returned_bytes = returned.len() as u64;
let content_utf8 = String::from_utf8_lossy(&returned).into_owned();
Ok(RpcOutcome::single_log(
ReadFileResult {
rel_path: rel_path.clone(),
size_on_disk,
returned_bytes,
truncated,
content_utf8,
},
format!(
"read_workspace_file {rel_path}: size_on_disk={size_on_disk}, truncated={truncated}"
),
))
}
// ── in_flight_chats ───────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct InFlightEntryView {
pub key: String,
pub request_id: String,
}
#[derive(Debug, Serialize)]
pub struct InFlightResult {
pub entries: Vec<InFlightEntryView>,
}
pub async fn in_flight_chats() -> Result<RpcOutcome<InFlightResult>, String> {
let entries: Vec<InFlightEntryView> = in_flight_entries_for_test()
.await
.into_iter()
.map(|(key, request_id)| InFlightEntryView { key, request_id })
.collect();
let count = entries.len();
Ok(RpcOutcome::single_log(
InFlightResult { entries },
format!("in_flight_chats: {count} entries"),
))
}
+4
View File
@@ -5,7 +5,11 @@
//! the process. As new domains add persistent state, extend `rpc::reset` to
//! wipe them too — every new domain that survives a `test_reset` is a leak
//! that will make specs interfere with each other.
//!
//! `introspect` adds read-only RPCs that let specs verify state on disk
//! and in the live process (workspace tree, files, IN_FLIGHT chat map).
pub mod introspect;
pub mod rpc;
mod schemas;
+197 -7
View File
@@ -2,18 +2,42 @@ use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::test_support::rpc;
use crate::openhuman::test_support::{introspect, rpc};
use crate::rpc::RpcOutcome;
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![schemas("reset")]
vec![
schemas("reset"),
schemas("workspace_root"),
schemas("list_workspace_files"),
schemas("read_workspace_file"),
schemas("in_flight_chats"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![RegisteredController {
schema: schemas("reset"),
handler: handle_reset,
}]
vec![
RegisteredController {
schema: schemas("reset"),
handler: handle_reset,
},
RegisteredController {
schema: schemas("workspace_root"),
handler: handle_workspace_root,
},
RegisteredController {
schema: schemas("list_workspace_files"),
handler: handle_list_workspace_files,
},
RegisteredController {
schema: schemas("read_workspace_file"),
handler: handle_read_workspace_file,
},
RegisteredController {
schema: schemas("in_flight_chats"),
handler: handle_in_flight_chats,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
@@ -59,8 +83,140 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"workspace_root" => ControllerSchema {
namespace: "test_support",
function: "workspace_root",
description: "Return the active workspace_dir path and whether it exists on disk.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Object {
fields: vec![
FieldSchema {
name: "path",
ty: TypeSchema::String,
comment: "Absolute workspace path.",
required: true,
},
FieldSchema {
name: "exists",
ty: TypeSchema::Bool,
comment: "Whether the workspace dir exists on disk right now.",
required: true,
},
],
},
comment: "Workspace root metadata.",
required: true,
}],
},
"list_workspace_files" => ControllerSchema {
namespace: "test_support",
function: "list_workspace_files",
description:
"Recursively list files under the workspace (or a sub-path). Capped at depth 6 \
and 2000 entries. Returns relative paths plus byte size and is_dir flag.",
inputs: vec![
FieldSchema {
name: "rel_root",
ty: TypeSchema::String,
comment: "Optional workspace-relative sub-path to list. Defaults to the whole workspace.",
required: false,
},
FieldSchema {
name: "max_depth",
ty: TypeSchema::U64,
comment: "Optional max recursion depth (capped at 6). Defaults to 2.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Object {
fields: vec![
FieldSchema {
name: "root",
ty: TypeSchema::String,
comment: "Absolute root path that was walked.",
required: true,
},
FieldSchema {
name: "truncated",
ty: TypeSchema::Bool,
comment: "True when the 2000-entry cap was hit.",
required: true,
},
],
},
comment: "Listing result (entries omitted from schema for brevity).",
required: true,
}],
},
"read_workspace_file" => ControllerSchema {
namespace: "test_support",
function: "read_workspace_file",
description:
"Read a workspace-relative file (lossy UTF-8). Capped at 1 MiB. Rejects paths \
that escape the workspace root via `..` etc.",
inputs: vec![
FieldSchema {
name: "rel_path",
ty: TypeSchema::String,
comment: "Workspace-relative file path.",
required: true,
},
FieldSchema {
name: "max_bytes",
ty: TypeSchema::U64,
comment: "Optional read cap (clamped at 1 MiB).",
required: false,
},
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Object {
fields: vec![
FieldSchema {
name: "content_utf8",
ty: TypeSchema::String,
comment: "File contents decoded with lossy UTF-8.",
required: true,
},
FieldSchema {
name: "truncated",
ty: TypeSchema::Bool,
comment: "True when the file exceeded max_bytes.",
required: true,
},
],
},
comment: "File contents.",
required: true,
}],
},
"in_flight_chats" => ControllerSchema {
namespace: "test_support",
function: "in_flight_chats",
description:
"Snapshot the IN_FLIGHT chat map: which (client_id, thread_id) pairs are \
currently running a chat turn and their request_id.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Object {
fields: vec![FieldSchema {
name: "entries",
ty: TypeSchema::String,
comment: "Array of { key, request_id } entries (serialized).",
required: true,
}],
},
comment: "Snapshot of running chats.",
required: true,
}],
},
_other => ControllerSchema {
namespace: "test",
namespace: "test_support",
function: "unknown",
description: "Unknown test-support controller function.",
inputs: vec![FieldSchema {
@@ -83,6 +239,40 @@ fn handle_reset(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(rpc::reset().await?) })
}
fn handle_workspace_root(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(introspect::workspace_root().await?) })
}
fn handle_list_workspace_files(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let rel_root = params
.get("rel_root")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let max_depth = params
.get("max_depth")
.and_then(|v| v.as_u64())
.map(|d| d as u32);
to_json(introspect::list_workspace_files(rel_root, max_depth).await?)
})
}
fn handle_read_workspace_file(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let rel_path = params
.get("rel_path")
.and_then(|v| v.as_str())
.ok_or_else(|| "rel_path is required".to_string())?
.to_string();
let max_bytes = params.get("max_bytes").and_then(|v| v.as_u64());
to_json(introspect::read_workspace_file(rel_path, max_bytes).await?)
})
}
fn handle_in_flight_chats(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(introspect::in_flight_chats().await?) })
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}