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:
@@ -52,8 +52,6 @@ fn git(dir: &Path, args: &[&str]) -> String {
|
||||
kigi_test_utils::git::run_git(dir, args)
|
||||
}
|
||||
|
||||
// ── scan counting ─────────────────────────────────────────────────────────
|
||||
|
||||
use kigi_hunk_tracker::{REFRESH_SCAN_LOG_PREFIX, REFRESH_SKIP_LOG_PREFIX};
|
||||
use kigi_test_utils::tracing_capture::MessagePrefixCounter;
|
||||
|
||||
@@ -84,8 +82,6 @@ fn install_global_scan_counter() -> ScanCounter {
|
||||
)
|
||||
}
|
||||
|
||||
// ── repo fixture ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Committed tree of ~`files` files plus a `feature` branch with `picks`
|
||||
/// one-file commits and an advanced base branch, `feature` checked out.
|
||||
/// Returns the repo dir and the base branch name.
|
||||
@@ -105,8 +101,6 @@ fn build_repo(files: usize, picks: usize) -> (TempDir, String) {
|
||||
(dir, base)
|
||||
}
|
||||
|
||||
// ── scripted responses (chat-completions SSE) ────────────────────────────
|
||||
|
||||
fn chat_chunk(delta: Value, finish_reason: Value) -> SseEvent {
|
||||
SseEvent::data(
|
||||
json!({
|
||||
@@ -151,8 +145,6 @@ fn text_sse(text: &str) -> ScriptedResponse {
|
||||
ScriptedResponse::sse(events)
|
||||
}
|
||||
|
||||
// ── client ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Auto-approves permissions (AllowOnce preferred) and drops notifications.
|
||||
struct AutoApproveClient;
|
||||
|
||||
@@ -181,8 +173,6 @@ impl acp::Client for AutoApproveClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── one full agent run ────────────────────────────────────────────────────
|
||||
|
||||
struct RunStats {
|
||||
scans: usize,
|
||||
skips: usize,
|
||||
|
||||
@@ -37,8 +37,6 @@ use kigi_shell::session::storage::{
|
||||
};
|
||||
use kigi_workspace::session::file_state::{FileSnapshot, FlexiblePath, RewindPoint};
|
||||
|
||||
// ───────────────────────── size knobs ─────────────────────────
|
||||
|
||||
/// Generation parameters. Defaults produce a session large enough that the
|
||||
/// per-phase costs are clearly measurable (tens of MB) while still finishing
|
||||
/// in a few seconds. Scale up via env to approach a real heavy session.
|
||||
@@ -81,8 +79,6 @@ impl GenOpts {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── filler ─────────────────────────
|
||||
|
||||
/// Deterministic, non-trivially-compressible-ish filler of `n` bytes. Uses a
|
||||
/// rotating word list so serde has real strings to allocate (not one repeated
|
||||
/// byte), matching the cost profile of real prose/code content.
|
||||
@@ -102,8 +98,6 @@ fn filler(n: usize) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
// ───────────────────────── update synthesis ─────────────────────────
|
||||
|
||||
fn sid(session_id: &str) -> acp::SessionId {
|
||||
acp::SessionId::new(session_id.to_string())
|
||||
}
|
||||
@@ -202,8 +196,6 @@ fn generate_rewind_jsonl(path: &Path, opts: &GenOpts) {
|
||||
std::fs::write(path, out).expect("write rewind_points.jsonl");
|
||||
}
|
||||
|
||||
// ───────────────────────── session setup ─────────────────────────
|
||||
|
||||
/// Find `<root>/sessions/<enc-cwd>/<id>` without depending on the (internal)
|
||||
/// cwd encoder: scan the one level of cwd dirs for a child named `<id>`.
|
||||
fn locate_session_dir(root: &Path, id: &str) -> PathBuf {
|
||||
@@ -223,7 +215,6 @@ fn locate_session_dir(root: &Path, id: &str) -> PathBuf {
|
||||
);
|
||||
}
|
||||
|
||||
/// Recursively copy a directory tree.
|
||||
fn copy_tree(src: &Path, dst: &Path) {
|
||||
std::fs::create_dir_all(dst).unwrap();
|
||||
for entry in std::fs::read_dir(src).unwrap().flatten() {
|
||||
@@ -369,8 +360,6 @@ fn print_kind_breakdown(label: &str, stats: &KindStats) {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── TEST 1: phase breakdown ─────────────────────────
|
||||
|
||||
/// Attribute the pre-render load cost to its real phases using the exact
|
||||
/// production functions, isolating rewind-point load from everything else.
|
||||
///
|
||||
@@ -405,14 +394,14 @@ async fn phase_breakdown_real_functions() {
|
||||
.await
|
||||
.expect("load_session_without_updates");
|
||||
let full_load_light = t.elapsed();
|
||||
// load_light no longer reads rewind_points.jsonl (deferred/lazy), so 0 by
|
||||
// construction — `PersistedDataLight` has no rewind field.
|
||||
// load_light does not read rewind_points.jsonl (rewind load is deferred/lazy);
|
||||
// `PersistedDataLight` has no rewind field, so this is 0 by construction.
|
||||
let light_rewind_in_load = 0usize;
|
||||
drop(light);
|
||||
|
||||
// Lazy rewind path (T2): the deferred cost moved here. The picker only needs
|
||||
// a cheap metadata scan; an actual rewind triggers the full content load.
|
||||
// Both read the same file that `load_light` no longer touches.
|
||||
// Lazy rewind path (T2): the picker only needs a cheap metadata scan; an
|
||||
// actual rewind triggers the full content load. Both read the same file
|
||||
// that `load_light` does not touch.
|
||||
use kigi_workspace::session::file_state::FileStateTracker;
|
||||
let t = Instant::now();
|
||||
let lazy_metas = FileStateTracker::with_lazy_source(rewind_path.clone())
|
||||
@@ -504,8 +493,6 @@ fn generate_or_restore_rewind(path: &Path, opts: &GenOpts) {
|
||||
generate_rewind_jsonl(path, opts);
|
||||
}
|
||||
|
||||
// ───────────────────────── TEST 2: true e2e ─────────────────────────
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -20,19 +20,18 @@ fn full_lifecycle() {
|
||||
let pid = std::process::id();
|
||||
let sid = |s: &str| agent_client_protocol::SessionId::new(s);
|
||||
|
||||
// Start session, verify listed.
|
||||
register_in(r, session("s1", pid)).unwrap();
|
||||
assert_eq!(list_in(r).unwrap().len(), 1);
|
||||
|
||||
// Clean exit, verify gone.
|
||||
unregister_in(r, &sid("s1")).unwrap();
|
||||
assert!(list_in(r).unwrap().is_empty());
|
||||
|
||||
// Simulate crash (dead PID) + live session.
|
||||
// 2_000_000_000 is not a live PID on this machine, simulating a crash.
|
||||
register_in(r, session("crashed", 2_000_000_000)).unwrap();
|
||||
register_in(r, session("alive", pid)).unwrap();
|
||||
|
||||
// Crash detection finds dead PID, keeps live one.
|
||||
// collect_crashed_in also reaps the dead entry from the store, so
|
||||
// list_in below drops from 2 to 1.
|
||||
let crashed = collect_crashed_in(r).unwrap();
|
||||
assert_eq!(crashed.len(), 1);
|
||||
assert_eq!(&*crashed[0].session_id.0, "crashed");
|
||||
|
||||
@@ -40,6 +40,7 @@ fn invalidate_models_cache(home: &std::path::Path) {
|
||||
}
|
||||
/// Start a mock server with two models:
|
||||
/// - `default-model`: no agent_type (→ defaults to "kigi")
|
||||
/// - `cursor-model`: agent_type "cursor"
|
||||
async fn dual_model_server() -> MockInferenceServer {
|
||||
MockInferenceServer::start_with_models(vec![
|
||||
MockModelEntry::new("default-model"),
|
||||
|
||||
@@ -41,7 +41,6 @@ where
|
||||
tokio::task::LocalSet::new().run_until(f()).await;
|
||||
}
|
||||
|
||||
/// Start a mock server with one model named `model` on the given API backend.
|
||||
async fn single_model_server(model: &str, backend: &str) -> MockInferenceServer {
|
||||
MockInferenceServer::start_with_models(vec![
|
||||
MockModelEntry::new(model).with_api_backend(backend),
|
||||
@@ -124,14 +123,10 @@ async fn run_headless_with_env(
|
||||
run_headless_with_cmd(cmd).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Smoke tests
|
||||
// ============================================================================
|
||||
|
||||
/// Smoke test: the binary loads and exits without crashing.
|
||||
/// This does NOT require the mock server — it's the absolute minimum bar.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_version_exits_zero() {
|
||||
let binary = kigi_binary();
|
||||
let output = Command::new(&binary)
|
||||
@@ -151,7 +146,7 @@ async fn test_version_exits_zero() {
|
||||
/// Exercises install() (sigaction, sigaltstack, mmap, ucontext struct layouts)
|
||||
/// on every platform the binary is built for.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_version_with_crash_handler_exits_zero() {
|
||||
let binary = kigi_binary();
|
||||
let output = Command::new(&binary)
|
||||
@@ -175,7 +170,7 @@ async fn test_version_with_crash_handler_exits_zero() {
|
||||
/// This catches the recurring libgit2/OpenSSL dynamic linking bug that has
|
||||
/// caused ~5 broken releases.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_session_in_git_repo() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
@@ -200,7 +195,7 @@ async fn test_headless_session_in_git_repo() {
|
||||
/// Verify kigi works in a non-git directory (exercises the fallback codepath
|
||||
/// where libgit2 discovers there's no repo instead of initializing one).
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_session_in_non_git_dir() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
@@ -215,7 +210,7 @@ async fn test_headless_session_in_non_git_dir() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_tools_allowlist_keeps_enabled_web_tools() {
|
||||
let server = kigi_server().await;
|
||||
server.preset_allow_access();
|
||||
@@ -273,7 +268,7 @@ async fn test_headless_tools_allowlist_keeps_enabled_web_tools() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_tools_allowlist_does_not_fail_open_for_disabled_web_fetch() {
|
||||
let server = kigi_server().await;
|
||||
server.set_settings(serde_json::json!({
|
||||
@@ -313,7 +308,7 @@ async fn test_headless_tools_allowlist_does_not_fail_open_for_disabled_web_fetch
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_terminal_only_allowlist_is_foreground_only() {
|
||||
let server = kigi_server().await;
|
||||
let workdir = git_workdir();
|
||||
@@ -351,7 +346,7 @@ async fn test_headless_terminal_only_allowlist_is_foreground_only() {
|
||||
/// code reaches the pager embedded in the flattened error text (no
|
||||
/// structured plumbing), so this exercises the whole detection path.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_free_usage_exhausted_prints_paywall_message() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
@@ -400,7 +395,7 @@ async fn test_headless_free_usage_exhausted_prints_paywall_message() {
|
||||
/// Verify the streaming JSON output format works end-to-end.
|
||||
/// This is the format used by programmatic integrations (`--output-format streaming-json`).
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_streaming_json_output() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
@@ -464,7 +459,7 @@ async fn test_headless_streaming_json_output() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_json_reports_server_cost() {
|
||||
use kigi_test_support::scripted::SseEvent;
|
||||
|
||||
@@ -526,7 +521,7 @@ async fn test_headless_json_reports_server_cost() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_json_reports_usage_on_max_turns() {
|
||||
let server = single_model_server("kigi-4.5", "chat_completions").await;
|
||||
server.enqueue_response(
|
||||
@@ -567,7 +562,7 @@ async fn test_headless_json_reports_usage_on_max_turns() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_streaming_json_usage() {
|
||||
let server = single_model_server("kigi-4.5", "chat_completions").await;
|
||||
let workdir = git_workdir();
|
||||
@@ -602,7 +597,7 @@ async fn test_headless_streaming_json_usage() {
|
||||
/// `response_format`, and the model's final JSON answer surfaces as
|
||||
/// `structuredOutput`. The StructuredOutput tool is NOT used.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn headless_json_schema_chat_completions_uses_response_format() {
|
||||
let server = single_model_server("kigi-4.5", "chat_completions").await;
|
||||
server.set_response(r#"{"name":"Alice","age":30}"#);
|
||||
@@ -661,7 +656,7 @@ async fn headless_json_schema_chat_completions_uses_response_format() {
|
||||
/// Responses backend: native schema rides `text.format` (not the tool), and the
|
||||
/// final JSON answer surfaces as `structuredOutput`.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn headless_json_schema_responses_uses_text_format() {
|
||||
let server = single_model_server("kigi-4.5", "responses").await;
|
||||
server.set_response(r#"{"name":"Alice","age":30}"#);
|
||||
@@ -714,7 +709,7 @@ async fn headless_json_schema_responses_uses_text_format() {
|
||||
/// Verifies the tool reaches the wire and its validated args surface as
|
||||
/// `structuredOutput`.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn headless_json_schema_messages_backend_uses_structured_output_tool() {
|
||||
let server = single_model_server("messages-compatible-model", "messages").await;
|
||||
server.enqueue_response(
|
||||
@@ -756,7 +751,6 @@ async fn headless_json_schema_messages_backend_uses_structured_output_tool() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether any request advertised a tool named `StructuredOutput` in `tools[]`.
|
||||
fn any_request_advertises_structured_output_tool(server: &MockInferenceServer) -> bool {
|
||||
server.requests().iter().any(|r| {
|
||||
r.body.as_ref().is_some_and(|body| {
|
||||
@@ -799,7 +793,7 @@ const NAME_AGE_SCHEMA: &str = r#"{"type":"object","properties":{"name":{"type":"
|
||||
/// prose: the turn-end fallback still validates the text against the schema and
|
||||
/// surfaces `structuredOutput` (closes the "unvalidated fallback" gap).
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn headless_json_schema_messages_validates_text_when_tool_not_called() {
|
||||
let server = single_model_server("messages-compatible-model", "messages").await;
|
||||
server.set_response(r#"{"name":"Cara","age":7}"#);
|
||||
@@ -839,7 +833,7 @@ async fn headless_json_schema_messages_validates_text_when_tool_not_called() {
|
||||
/// the agent feeds the error back and the model's retry conforms. Exercises the
|
||||
/// validation + bounded-retry path.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn headless_json_schema_messages_retries_on_schema_violation() {
|
||||
let server = single_model_server("messages-compatible-model", "messages").await;
|
||||
server.enqueue_response(
|
||||
@@ -885,7 +879,7 @@ async fn headless_json_schema_messages_retries_on_schema_violation() {
|
||||
/// An invalid `--json-schema` (valid JSON object, but fails schema compilation)
|
||||
/// disables both structured-output paths and surfaces the compile error.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn invalid_json_schema_disables_structured_output_and_surfaces_error() {
|
||||
let server = single_model_server("kigi-4.5", "chat_completions").await;
|
||||
server.set_response(r#"{"name":"Alice","age":30}"#);
|
||||
@@ -949,25 +943,22 @@ async fn invalid_json_schema_disables_structured_output_and_surfaces_error() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACP stdio tests (kigi agent stdio)
|
||||
//
|
||||
// These test the agent as a server: spawn `kigi agent stdio`, speak the full
|
||||
// ACP protocol over pipes, verify the lifecycle works end-to-end.
|
||||
// ============================================================================
|
||||
|
||||
/// Full ACP lifecycle: initialize → authenticate → create session → prompt.
|
||||
/// Verifies the agent boots, authenticates with a test API key, creates a
|
||||
/// session (libgit2 init), and completes a prompt round-trip to the mock server.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_stdio_full_session_lifecycle() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start().await.expect("start mock server");
|
||||
let workdir = git_workdir();
|
||||
let client = KigiStdioClient::spawn(&server, workdir.path()).await;
|
||||
|
||||
// Initialize and authenticate
|
||||
let init_resp = client.initialize_with_timeout().await;
|
||||
assert!(
|
||||
!init_resp.auth_methods.is_empty(),
|
||||
@@ -990,7 +981,6 @@ async fn test_stdio_full_session_lifecycle() {
|
||||
stderr_tail(&client.stderr(), 1200)
|
||||
);
|
||||
|
||||
// Verify the mock server received at least one inference request
|
||||
assert!(
|
||||
server.request_count() > 0,
|
||||
"mock server received no inference requests\nrequest log:\n{}\nstderr:\n{}",
|
||||
@@ -1005,7 +995,7 @@ async fn test_stdio_full_session_lifecycle() {
|
||||
/// Creates a session, closes it via ext_method, then verifies session/info
|
||||
/// returns an empty response (session no longer exists).
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_stdio_session_close() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
@@ -1017,7 +1007,6 @@ async fn test_stdio_session_close() {
|
||||
client.initialize_with_timeout().await;
|
||||
let session_id = client.create_session_with_timeout(workdir.path()).await;
|
||||
|
||||
// Session should be alive — session/info returns data with sessionId
|
||||
let info_resp = client
|
||||
.ext_method(
|
||||
"kigi/session/info",
|
||||
@@ -1036,7 +1025,6 @@ async fn test_stdio_session_close() {
|
||||
"session/info should return the session we created, got: {info}"
|
||||
);
|
||||
|
||||
// Close the session
|
||||
let close_resp = client
|
||||
.ext_method(
|
||||
"kigi/session/close",
|
||||
@@ -1050,7 +1038,6 @@ async fn test_stdio_session_close() {
|
||||
stderr_tail(&client.stderr(), 1200)
|
||||
);
|
||||
|
||||
// Session should be gone — session/info returns empty result (no sessionId)
|
||||
let info_after = client
|
||||
.ext_method(
|
||||
"kigi/session/info",
|
||||
@@ -1069,7 +1056,7 @@ async fn test_stdio_session_close() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_stdio_prompt_then_immediate_load_session() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start().await.expect("start mock server");
|
||||
@@ -1115,7 +1102,7 @@ async fn test_stdio_prompt_then_immediate_load_session() {
|
||||
.await;
|
||||
}
|
||||
|
||||
// ── Raw-wire stdio driving (Xcode / Foundation shape) ───────────────────────
|
||||
// Raw-wire stdio driving (Xcode / Foundation shape)
|
||||
|
||||
/// Serialize `req` compactly, then rewrite its method to the Foundation-escaped
|
||||
/// form (`"session/new"` → `"session\/new"`) by string surgery, asserting the
|
||||
@@ -1146,7 +1133,7 @@ fn line_with_escaped_method(req: &serde_json::Value, method: &str) -> String {
|
||||
/// request hung forever. Drives the built binary with the raw wire bytes and
|
||||
/// asserts every escaped-method request gets a response.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_stdio_xcode_escaped_slash_methods_get_responses() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
@@ -1249,7 +1236,7 @@ async fn test_stdio_xcode_escaped_slash_methods_get_responses() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Config test harness ─────────────────────────────────────────────────────
|
||||
// Config test harness
|
||||
|
||||
/// Isolated headless run with a custom `~/.kigi/`. Clean env (no leaked
|
||||
/// host credentials). Write config files into `kigi_dir()` before `run()`.
|
||||
@@ -1308,13 +1295,13 @@ impl ConfigTestHarness {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enterprise managed config tests ────────────────────────────────────────
|
||||
// Enterprise managed config tests
|
||||
|
||||
/// Enterprise BYOK: managed_config.toml overrides kigi with a custom
|
||||
/// endpoint + env_key. Mock rejects unauthenticated requests with 401.
|
||||
/// Regression guard for the 0.1.220 authentication regression.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_managed_config_byok_sends_authorized_requests() {
|
||||
let server = MockInferenceServer::start_with_required_auth(
|
||||
vec![MockModelEntry::new("kigi-4.5")],
|
||||
@@ -1366,7 +1353,7 @@ default = "kigi-4.5"
|
||||
/// path the wire effort comes from the legacy scalar, not from the list; the
|
||||
/// list→default derivation is unit-tested in `acp_model_meta_*`.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn headless_reasoning_efforts_payload_parses_and_legacy_effort_rides_wire() {
|
||||
let server = MockInferenceServer::start_with_models(vec![
|
||||
MockModelEntry::new("kigi-4.5")
|
||||
@@ -1416,9 +1403,7 @@ async fn headless_reasoning_efforts_payload_parses_and_legacy_effort_rides_wire(
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Background-task reaping at headless exit
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(unix)]
|
||||
use kigi_test_support::sse::{
|
||||
@@ -1505,7 +1490,7 @@ fn enqueue_background_task_turn(server: &MockInferenceServer, pid_file: &std::pa
|
||||
/// orphaning it.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_timeout_exit_kills_pending_background_task() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
@@ -1546,7 +1531,7 @@ async fn test_headless_timeout_exit_kills_pending_background_task() {
|
||||
/// task — tracked despite the flag — must still be killed, not leaked.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_no_wait_exit_kills_background_task() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
@@ -1583,7 +1568,7 @@ async fn test_headless_no_wait_exit_kills_background_task() {
|
||||
/// nothing reaped.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_headless_waits_for_short_background_task_and_exits_clean() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
|
||||
@@ -41,8 +41,6 @@ async fn update_config_does_not_leak_requirements_into_user_config() {
|
||||
let home = test_home();
|
||||
reset_config_files(home);
|
||||
|
||||
// --- Arrange ---
|
||||
|
||||
// User's config.toml: auto_update = true
|
||||
fs::write(
|
||||
home.join("config.toml"),
|
||||
@@ -67,7 +65,6 @@ async fn update_config_does_not_leak_requirements_into_user_config() {
|
||||
"precondition: effective config should merge requirements (auto_update=false)"
|
||||
);
|
||||
|
||||
// --- Act ---
|
||||
// Simulate an unrelated config write (e.g. persisting a model preference).
|
||||
kigi_shell::util::config::update_config(|cfg| {
|
||||
cfg.models.default = Some("kigi-3".to_string());
|
||||
@@ -75,7 +72,6 @@ async fn update_config_does_not_leak_requirements_into_user_config() {
|
||||
.await
|
||||
.expect("update_config should succeed");
|
||||
|
||||
// --- Assert ---
|
||||
// Read the user's config.toml back from disk (raw, no merge).
|
||||
let raw = fs::read_to_string(home.join("config.toml")).unwrap();
|
||||
let user_toml: toml::Value = toml::from_str(&raw).unwrap();
|
||||
@@ -98,28 +94,24 @@ async fn update_config_preserves_none_when_only_requirements_sets_value() {
|
||||
let home = test_home();
|
||||
reset_config_files(home);
|
||||
|
||||
// User config has no auto_update field at all
|
||||
fs::write(
|
||||
home.join("config.toml"),
|
||||
"[cli]\ninstaller = \"internal\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// requirements.toml sets auto_update = false
|
||||
fs::write(
|
||||
home.join("requirements.toml"),
|
||||
"[cli]\nauto_update = false\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Write an unrelated field
|
||||
kigi_shell::util::config::update_config(|cfg| {
|
||||
cfg.ui.yolo = true;
|
||||
})
|
||||
.await
|
||||
.expect("update_config should succeed");
|
||||
|
||||
// Read back
|
||||
let raw = fs::read_to_string(home.join("config.toml")).unwrap();
|
||||
let user_toml: toml::Value = toml::from_str(&raw).unwrap();
|
||||
let user_cfg = kigi_shell::util::config::load_config_from_toml(&user_toml);
|
||||
@@ -137,14 +129,12 @@ async fn update_config_does_not_leak_managed_config_values() {
|
||||
let home = test_home();
|
||||
reset_config_files(home);
|
||||
|
||||
// User config has no auto_update — only installer
|
||||
fs::write(
|
||||
home.join("config.toml"),
|
||||
"[cli]\ninstaller = \"internal\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// managed_config.toml sets auto_update = false and channel = "stable"
|
||||
fs::write(
|
||||
home.join("managed_config.toml"),
|
||||
"[cli]\nauto_update = false\nchannel = \"stable\"\n",
|
||||
|
||||
@@ -134,7 +134,8 @@ async fn read_session_firehose_when_ready(path: &Path, client: &KigiStdioClient)
|
||||
/// headless `kigi -p` client is near-silent, so its lazily-opened firehose may
|
||||
/// legitimately stay empty here — file existence is intentionally not asserted.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn debug_flag_enables_firehose_without_crashing() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
@@ -151,7 +152,8 @@ async fn debug_flag_enables_firehose_without_crashing() {
|
||||
|
||||
/// Without `--debug` (and no firehose env), no firehose files are written.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn no_debug_flag_writes_no_debug_dir() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
@@ -175,7 +177,8 @@ async fn no_debug_flag_writes_no_debug_dir() {
|
||||
/// `init_tracing_simple("agent")` path the spawned leader uses, so it covers
|
||||
/// leader capture deterministically without a flaky detached process.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn agent_session_writes_named_session_file() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
@@ -224,7 +227,8 @@ async fn agent_session_writes_named_session_file() {
|
||||
/// per-session file has first-party content (would FAIL pre-fix), and that
|
||||
/// sampling/instrumentation are NOT enabled by `--debug`.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn debug_flag_master_switch_enables_firehose() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
@@ -274,7 +278,8 @@ async fn debug_flag_master_switch_enables_firehose() {
|
||||
/// `--debug-file <path>` writes one explicit file and bypasses per-session
|
||||
/// routing entirely (no `~/.kigi/debug/` files created).
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn debug_file_flag_writes_single_file_and_bypasses_routing() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
@@ -309,7 +314,8 @@ async fn debug_file_flag_writes_single_file_and_bypasses_routing() {
|
||||
|
||||
/// `KIGI_LOG_FILE=<path>` (no `--debug`) writes that exact file (back-compat).
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn kigi_log_file_explicit_path_is_written() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
|
||||
@@ -63,9 +63,7 @@ fn responses_request_count(server: &MockInferenceServer) -> usize {
|
||||
.count()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger parsing (live)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Mid-stream check frames populate `doom_loop_signals`, deduplicated across
|
||||
/// the cumulative re-sends, with the label grammar fully parsed.
|
||||
@@ -283,9 +281,7 @@ async fn disabled_policy_leaves_terminal_field_unparsed() {
|
||||
assert!(response.assistant_text().contains("an answer"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery contract (the acceptance spec for the resample behavior)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A confident signal (`tail_repetition:8@thinking` at the default
|
||||
/// `max_threshold` 8) on a completed turn is resampled once: two requests,
|
||||
@@ -512,9 +508,7 @@ async fn doomed_then_reasoning_only_empty_coexist() {
|
||||
assert!(response.doom_loop_signals.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Headless lifecycle lane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `[doom_loop_recovery] enabled = true` in `config.toml` reaches the wire
|
||||
/// through the real binary: the session TURN request (marked by
|
||||
@@ -529,7 +523,8 @@ async fn doomed_then_reasoning_only_empty_coexist() {
|
||||
/// cargo test -p kigi-shell --test test_doom_loop_recovery -- --ignored
|
||||
/// ```
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn headless_config_enables_doom_loop_check_header() {
|
||||
let models = vec![MockModelEntry::new(MODEL).with_api_backend("responses")];
|
||||
let server = MockInferenceServer::start_with_models(models)
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
//! Integration tests for the fork session flow.
|
||||
//!
|
||||
//! These tests verify the complete fork session flow:
|
||||
//! 1. Fork session data with parent tracking
|
||||
//! 2. Verify forked session has correct metadata
|
||||
//! 3. Test worktree creation from worktree types
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_shell::sampling::ConversationItem;
|
||||
@@ -11,7 +6,6 @@ use kigi_shell::session::info::Info;
|
||||
use kigi_shell::session::storage::{JsonlStorageAdapter, StorageAdapter};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper to create a test session in a temp directory
|
||||
async fn create_test_session(storage: &JsonlStorageAdapter, session_id: &str, cwd: &str) -> Info {
|
||||
let info = Info {
|
||||
id: acp::SessionId::new(session_id),
|
||||
@@ -21,11 +15,9 @@ async fn create_test_session(storage: &JsonlStorageAdapter, session_id: &str, cw
|
||||
let model_id = acp::ModelId::new("kigi-code-fast-1");
|
||||
storage.init_session(&info, model_id).await.unwrap();
|
||||
|
||||
// Add some chat messages
|
||||
let msg = ConversationItem::user("Hello world");
|
||||
storage.append_chat_message(&info, &msg).await.unwrap();
|
||||
|
||||
// Add an update
|
||||
let notification = acp::SessionNotification::new(
|
||||
acp::SessionId::new(session_id),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
||||
@@ -48,7 +40,6 @@ async fn test_fork_session_creates_new_session_with_parent_tracking() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let storage = JsonlStorageAdapter::with_root(temp_dir.path().to_path_buf());
|
||||
|
||||
// Create source session
|
||||
let source_info = create_test_session(&storage, "source-session-123", "/source/path").await;
|
||||
|
||||
let target_info = Info {
|
||||
@@ -68,11 +59,9 @@ async fn test_fork_session_creates_new_session_with_parent_tracking() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify result
|
||||
assert_eq!(result.chat_messages_copied, 1);
|
||||
assert_eq!(result.updates_copied, 1);
|
||||
|
||||
// Load the forked session and verify metadata
|
||||
let loaded = storage.load_session(&target_info).await.unwrap();
|
||||
|
||||
assert_eq!(loaded.summary.info.id.to_string(), "fork-session-456");
|
||||
@@ -84,10 +73,8 @@ async fn test_fork_session_creates_new_session_with_parent_tracking() {
|
||||
);
|
||||
assert!(loaded.summary.forked_at.is_some());
|
||||
|
||||
// Verify chat history was copied
|
||||
assert_eq!(loaded.chat_history.len(), 1);
|
||||
|
||||
// Verify updates were copied with transformed session ID
|
||||
assert_eq!(loaded.updates.len(), 1);
|
||||
match &loaded.updates[0] {
|
||||
kigi_shell::session::storage::SessionUpdate::Acp(notification) => {
|
||||
@@ -102,16 +89,13 @@ async fn test_fork_preserves_session_title() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let storage = JsonlStorageAdapter::with_root(temp_dir.path().to_path_buf());
|
||||
|
||||
// Create source session
|
||||
let source_info = create_test_session(&storage, "titled-session", "/source").await;
|
||||
|
||||
// Update source session with a title
|
||||
storage
|
||||
.update_session_title(&source_info, "My Important Session".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Fork the session
|
||||
let target_info = Info {
|
||||
id: acp::SessionId::new("fork-titled"),
|
||||
cwd: "/new".to_string(),
|
||||
@@ -129,7 +113,7 @@ async fn test_fork_preserves_session_title() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Load and verify title was preserved (generated_title is the LLM title field).
|
||||
// display_title() surfaces generated_title, the LLM-set title field.
|
||||
let loaded = storage.load_session(&target_info).await.unwrap();
|
||||
assert_eq!(loaded.summary.display_title(), "My Important Session");
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ use kigi_test_support::*;
|
||||
/// Every global `[models]` default is accepted, and the wire-observable
|
||||
/// `extra_headers` reaches the inference request with no per-model block in play.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn global_models_config_reaches_inference_request() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
|
||||
@@ -32,7 +32,8 @@ use kigi_test_support::*;
|
||||
/// THE repro. Kill the shared leader with SIGKILL while two clients are
|
||||
/// connected; both must recover their sessions on the re-elected leader.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_leader_sigkill_clients_recover_sessions() {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
@@ -41,7 +42,7 @@ async fn test_leader_sigkill_clients_recover_sessions() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(home.path().join(".kigi")).unwrap();
|
||||
|
||||
// ── Phase 1: two clients, one leader, two sessions ────────────
|
||||
// Phase 1: two clients, one leader, two sessions.
|
||||
let client_a = LeaderStdioClient::spawn(&server, workdir.path(), home.path()).await;
|
||||
client_a.initialize().await;
|
||||
let session_a = client_a.create_session(workdir.path()).await;
|
||||
@@ -72,7 +73,7 @@ async fn test_leader_sigkill_clients_recover_sessions() {
|
||||
assert_ne!(leader_pid, client_a.child.id().unwrap_or(0));
|
||||
assert_ne!(leader_pid, client_b.child.id().unwrap_or(0));
|
||||
|
||||
// ── Phase 2: SIGKILL the leader (simulated crash) ─────────────
|
||||
// Phase 2: SIGKILL the leader (simulated crash).
|
||||
let base_a = client_a.notification_count();
|
||||
let base_b = client_b.notification_count();
|
||||
eprintln!("killing leader pid {leader_pid}");
|
||||
@@ -80,7 +81,7 @@ async fn test_leader_sigkill_clients_recover_sessions() {
|
||||
libc::kill(leader_pid as i32, libc::SIGKILL);
|
||||
}
|
||||
|
||||
// ── Phase 3: clients must re-elect a leader and reconnect ─────
|
||||
// Phase 3: clients must re-elect a leader and reconnect.
|
||||
let new_pid = wait_for_new_leader(home.path(), leader_pid, Duration::from_secs(60))
|
||||
.await
|
||||
.unwrap_or_else(|| {
|
||||
@@ -100,7 +101,7 @@ async fn test_leader_sigkill_clients_recover_sessions() {
|
||||
wait_for_replay_notifications(&client_b, base_b, Duration::from_secs(60)).await;
|
||||
eprintln!("replay evidence: A={a_reconnected} B={b_reconnected}");
|
||||
|
||||
// ── Phase 4: prompts on the ORIGINAL session IDs must work ────
|
||||
// Phase 4: prompts on the ORIGINAL session IDs must work.
|
||||
let res_a = client_a.prompt(&session_a, "after crash A").await;
|
||||
let res_b = client_b.prompt(&session_b, "after crash B").await;
|
||||
|
||||
@@ -127,7 +128,8 @@ async fn test_leader_sigkill_clients_recover_sessions() {
|
||||
/// Single-client variant: kill -9 the leader, the lone client must re-elect
|
||||
/// and restore. Narrower failure surface than the two-client test.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_leader_sigkill_single_client_recovers() {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
@@ -185,7 +187,8 @@ async fn test_leader_sigkill_single_client_recovers() {
|
||||
/// re-elected leader — restoring only the most recent one left the other
|
||||
/// failing with "unknown session id".
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_leader_sigkill_multi_session_client_recovers_all_sessions() {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
@@ -228,7 +231,6 @@ async fn test_leader_sigkill_multi_session_client_recovers_all_sessions() {
|
||||
});
|
||||
wait_for_replay_notifications(&client, base, Duration::from_secs(60)).await;
|
||||
|
||||
// BOTH sessions must work on the new leader.
|
||||
let res_one = client.prompt(&session_one, "after crash one").await;
|
||||
let res_two = client.prompt(&session_two, "after crash two").await;
|
||||
assert!(
|
||||
@@ -254,7 +256,8 @@ async fn test_leader_sigkill_multi_session_client_recovers_all_sessions() {
|
||||
/// it once the session is restored — not silently drop it (which left the
|
||||
/// client's request hanging forever).
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_prompt_sent_during_outage_is_delivered_after_recovery() {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
|
||||
@@ -152,7 +152,7 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
// ── Leader server (survives client churn) ────────────────────
|
||||
// Leader server (survives client churn).
|
||||
let (acp_tx, mut acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let cancel = CancellationToken::new();
|
||||
@@ -185,7 +185,7 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
||||
.await;
|
||||
});
|
||||
|
||||
// ── Real agent behind it ──────────────────────────────────────
|
||||
// Real agent behind it.
|
||||
// Copied from `run_leader`'s agent-spawn + IPC/stdout bridge
|
||||
// blocks in src/agent/app.rs (inside its LocalSet body); kept as
|
||||
// a deliberate copy so production stays untouched. Second copy of
|
||||
@@ -256,7 +256,7 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
||||
}
|
||||
assert!(sock_path.exists(), "leader socket never bound");
|
||||
|
||||
// ── One-time initialize + authenticate through the leader ────
|
||||
// One-time initialize + authenticate through the leader.
|
||||
let mut bootstrap = LeaderClient::connect(
|
||||
sock_path.clone(),
|
||||
"soak-bootstrap",
|
||||
@@ -286,8 +286,8 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
||||
let mut cycles: u64 = 0;
|
||||
let mut turns: u64 = 0;
|
||||
|
||||
// ── Churn: 10 fresh clients per cycle, 2 sessions each, one
|
||||
// scripted turn per session, then all disconnect ───────────────
|
||||
// Churn: 10 fresh clients per cycle, 2 sessions each, one
|
||||
// scripted turn per session, then all disconnect.
|
||||
while tokio::time::Instant::now() < soak_deadline {
|
||||
cycles += 1;
|
||||
let mut clients = Vec::new();
|
||||
@@ -352,8 +352,8 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
||||
eprintln!("[soak] {cycles} cycles, {turns} turns in {soak_secs}s budget");
|
||||
assert!(cycles > 0, "soak budget too small to complete one cycle");
|
||||
|
||||
// ── Convergence: only the bootstrap client remains, and the
|
||||
// leader still serves a healthy round-trip ────────────────────
|
||||
// Convergence: only the bootstrap client remains, and the
|
||||
// leader still serves a healthy round-trip.
|
||||
assert_eq!(
|
||||
client_count.load(std::sync::atomic::Ordering::Relaxed),
|
||||
1,
|
||||
@@ -370,14 +370,14 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
||||
.await;
|
||||
assert!(resp["result"]["sessionId"].is_string());
|
||||
|
||||
// ── No response was ever dropped on a live-client send ────────
|
||||
// No response was ever dropped on a live-client send.
|
||||
assert_eq!(
|
||||
send_failed_count(),
|
||||
send_failed_before,
|
||||
"leader.response.send_failed must not occur during the soak"
|
||||
);
|
||||
|
||||
// ── RSS bound ─────────────────────────────────────────────────
|
||||
// RSS bound.
|
||||
if let (Some(before), Some(after)) = (rss_baseline, rss_bytes()) {
|
||||
let growth_mb = after.saturating_sub(before) as f64 / (1024.0 * 1024.0);
|
||||
eprintln!(
|
||||
|
||||
@@ -54,7 +54,6 @@ async fn setup_test_server(
|
||||
let sock_path = temp.path().join("leader.sock");
|
||||
let handle = spawn_leader_server(sock_path.clone()).await.unwrap();
|
||||
|
||||
// Wait for socket to be connectable instead of fixed sleep
|
||||
wait_for_socket(&sock_path).await;
|
||||
|
||||
(sock_path, handle.cancel, handle.acp_rx, handle.response_tx)
|
||||
@@ -92,7 +91,6 @@ async fn test_single_stdio_client_connects() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, _acp_rx, _response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Connect as stdio client
|
||||
let client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"test-stdio",
|
||||
@@ -102,7 +100,6 @@ async fn test_single_stdio_client_connects() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should be connected
|
||||
client.cancel();
|
||||
cancel.cancel();
|
||||
}
|
||||
@@ -122,7 +119,6 @@ async fn test_stdio_client_sends_acp_message() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send an ACP message (JSON-RPC format)
|
||||
let test_message = r#"{"jsonrpc":"2.0","method":"initialize","id":1}"#;
|
||||
client.send(test_message.to_string()).unwrap();
|
||||
|
||||
@@ -132,7 +128,6 @@ async fn test_stdio_client_sends_acp_message() {
|
||||
.expect("timeout waiting for message")
|
||||
.expect("channel closed");
|
||||
|
||||
// Parse and verify the message was received (ID will be namespaced)
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
assert_eq!(json["method"], "initialize");
|
||||
// ID should be namespaced with format "client_id<SEP>1" where 1 is the original ID
|
||||
@@ -160,7 +155,6 @@ async fn test_stdio_client_receives_response() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send request
|
||||
let test_message = r#"{"jsonrpc":"2.0","method":"test","id":42}"#;
|
||||
client.send(test_message.to_string()).unwrap();
|
||||
|
||||
@@ -176,7 +170,6 @@ async fn test_stdio_client_receives_response() {
|
||||
);
|
||||
response_tx.send(response).unwrap();
|
||||
|
||||
// Client should receive response with original ID restored
|
||||
let client_response = tokio::time::timeout(Duration::from_secs(2), client.recv())
|
||||
.await
|
||||
.expect("timeout waiting for response")
|
||||
@@ -196,7 +189,6 @@ async fn test_multiple_stdio_clients() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Connect two stdio clients
|
||||
let mut client1 = LeaderClient::connect(
|
||||
sock_path.clone(),
|
||||
"client-1",
|
||||
@@ -214,28 +206,23 @@ async fn test_multiple_stdio_clients() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client 1 sends a message
|
||||
client1
|
||||
.send(r#"{"jsonrpc":"2.0","method":"from_client_1","id":1}"#.to_string())
|
||||
.unwrap();
|
||||
|
||||
// Client 2 sends a message
|
||||
client2
|
||||
.send(r#"{"jsonrpc":"2.0","method":"from_client_2","id":2}"#.to_string())
|
||||
.unwrap();
|
||||
|
||||
// Server receives both messages
|
||||
let msg1 = acp_rx.recv().await.unwrap();
|
||||
let msg2 = acp_rx.recv().await.unwrap();
|
||||
|
||||
let json1: serde_json::Value = serde_json::from_str(&msg1).unwrap();
|
||||
let json2: serde_json::Value = serde_json::from_str(&msg2).unwrap();
|
||||
|
||||
// Messages should have different namespaced IDs (different client prefixes)
|
||||
let id1 = json1["id"].as_str().unwrap();
|
||||
let id2 = json2["id"].as_str().unwrap();
|
||||
|
||||
// Extract client ID prefixes using the Unit Separator
|
||||
let (client_id_1, _) = parse_namespaced_id(id1).expect("Should parse namespaced ID");
|
||||
let (client_id_2, _) = parse_namespaced_id(id2).expect("Should parse namespaced ID");
|
||||
assert_ne!(
|
||||
@@ -243,21 +230,18 @@ async fn test_multiple_stdio_clients() {
|
||||
"Clients should have different IDs"
|
||||
);
|
||||
|
||||
// Send response to client 1 using its namespaced ID
|
||||
let response1 = format!(
|
||||
r#"{{"jsonrpc":"2.0","result":"response_1","id":"{}"}}"#,
|
||||
id1
|
||||
);
|
||||
response_tx.send(response1).unwrap();
|
||||
|
||||
// Send response to client 2 using its namespaced ID
|
||||
let response2 = format!(
|
||||
r#"{{"jsonrpc":"2.0","result":"response_2","id":"{}"}}"#,
|
||||
id2
|
||||
);
|
||||
response_tx.send(response2).unwrap();
|
||||
|
||||
// Each client should receive its own response
|
||||
let recv1 = tokio::time::timeout(Duration::from_secs(2), client1.recv())
|
||||
.await
|
||||
.expect("timeout")
|
||||
@@ -270,7 +254,6 @@ async fn test_multiple_stdio_clients() {
|
||||
let recv_json1: serde_json::Value = serde_json::from_str(&recv1).unwrap();
|
||||
let recv_json2: serde_json::Value = serde_json::from_str(&recv2).unwrap();
|
||||
|
||||
// IDs should be restored to originals
|
||||
assert_eq!(recv_json1["id"], 1);
|
||||
assert_eq!(recv_json2["id"], 2);
|
||||
|
||||
@@ -287,7 +270,6 @@ async fn test_multiple_clients_same_message_ids() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Connect three stdio clients
|
||||
let mut client1 = LeaderClient::connect(
|
||||
sock_path.clone(),
|
||||
"client-1",
|
||||
@@ -324,7 +306,6 @@ async fn test_multiple_clients_same_message_ids() {
|
||||
.send(r#"{"jsonrpc":"2.0","method":"method_3","id":1}"#.to_string())
|
||||
.unwrap();
|
||||
|
||||
// Collect all three messages from the server
|
||||
let msg1 = acp_rx.recv().await.unwrap();
|
||||
let msg2 = acp_rx.recv().await.unwrap();
|
||||
let msg3 = acp_rx.recv().await.unwrap();
|
||||
@@ -333,7 +314,6 @@ async fn test_multiple_clients_same_message_ids() {
|
||||
let json2: serde_json::Value = serde_json::from_str(&msg2).unwrap();
|
||||
let json3: serde_json::Value = serde_json::from_str(&msg3).unwrap();
|
||||
|
||||
// All messages should have namespaced IDs with original ID "1"
|
||||
let id1 = json1["id"].as_str().unwrap();
|
||||
let id2 = json2["id"].as_str().unwrap();
|
||||
let id3 = json3["id"].as_str().unwrap();
|
||||
@@ -351,12 +331,10 @@ async fn test_multiple_clients_same_message_ids() {
|
||||
"ID should contain original ID 1"
|
||||
);
|
||||
|
||||
// But the full namespaced IDs should all be different (different client prefixes)
|
||||
assert_ne!(id1, id2, "Namespaced IDs should be unique");
|
||||
assert_ne!(id2, id3, "Namespaced IDs should be unique");
|
||||
assert_ne!(id1, id3, "Namespaced IDs should be unique");
|
||||
|
||||
// Build a map of method -> namespaced_id for targeted responses
|
||||
let mut method_to_id: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
method_to_id.insert(
|
||||
@@ -372,7 +350,6 @@ async fn test_multiple_clients_same_message_ids() {
|
||||
id3.to_string(),
|
||||
);
|
||||
|
||||
// Send responses back using the namespaced IDs - each with a unique result
|
||||
let resp1 = format!(
|
||||
r#"{{"jsonrpc":"2.0","result":"result_for_client_1","id":"{}"}}"#,
|
||||
method_to_id.get("method_1").unwrap()
|
||||
@@ -390,7 +367,6 @@ async fn test_multiple_clients_same_message_ids() {
|
||||
response_tx.send(resp2).unwrap();
|
||||
response_tx.send(resp3).unwrap();
|
||||
|
||||
// Each client should receive its own response with the original ID restored
|
||||
let recv1 = tokio::time::timeout(Duration::from_secs(2), client1.recv())
|
||||
.await
|
||||
.expect("timeout")
|
||||
@@ -408,12 +384,10 @@ async fn test_multiple_clients_same_message_ids() {
|
||||
let recv_json2: serde_json::Value = serde_json::from_str(&recv2).unwrap();
|
||||
let recv_json3: serde_json::Value = serde_json::from_str(&recv3).unwrap();
|
||||
|
||||
// All IDs should be restored to the original value (1)
|
||||
assert_eq!(recv_json1["id"], 1, "Client 1's ID should be restored to 1");
|
||||
assert_eq!(recv_json2["id"], 1, "Client 2's ID should be restored to 1");
|
||||
assert_eq!(recv_json3["id"], 1, "Client 3's ID should be restored to 1");
|
||||
|
||||
// Each client should have received its unique result
|
||||
assert_eq!(recv_json1["result"], "result_for_client_1");
|
||||
assert_eq!(recv_json2["result"], "result_for_client_2");
|
||||
assert_eq!(recv_json3["result"], "result_for_client_3");
|
||||
@@ -430,11 +404,9 @@ async fn test_stdio_client_disconnect() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, _acp_rx, _response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Connect and then disconnect
|
||||
let stream = UnixStream::connect(&sock_path).await.unwrap();
|
||||
let (mut reader, mut writer) = tokio::io::split(stream);
|
||||
|
||||
// Register
|
||||
write_message(
|
||||
&mut writer,
|
||||
&ClientMessage::Register {
|
||||
@@ -449,7 +421,6 @@ async fn test_stdio_client_disconnect() {
|
||||
let response: ServerMessage = read_message(&mut reader).await.unwrap();
|
||||
assert!(matches!(response, ServerMessage::Registered { .. }));
|
||||
|
||||
// Send disconnect message
|
||||
write_message(&mut writer, &ClientMessage::Disconnect)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -469,7 +440,6 @@ async fn test_stdio_client_ping_pong() {
|
||||
let stream = UnixStream::connect(&sock_path).await.unwrap();
|
||||
let (mut reader, mut writer) = tokio::io::split(stream);
|
||||
|
||||
// Register first
|
||||
write_message(
|
||||
&mut writer,
|
||||
&ClientMessage::Register {
|
||||
@@ -482,12 +452,10 @@ async fn test_stdio_client_ping_pong() {
|
||||
.unwrap();
|
||||
let _: ServerMessage = read_message(&mut reader).await.unwrap();
|
||||
|
||||
// Send ping
|
||||
write_message(&mut writer, &ClientMessage::Ping)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should receive pong
|
||||
let response: ServerMessage = read_message(&mut reader).await.unwrap();
|
||||
assert!(matches!(response, ServerMessage::Pong));
|
||||
|
||||
@@ -501,10 +469,8 @@ async fn test_server_exits_when_all_clients_disconnect() {
|
||||
let sock_path = temp.path().join("leader.sock");
|
||||
let handle = spawn_leader_server(sock_path.clone()).await.unwrap();
|
||||
|
||||
// Wait for socket to be connectable
|
||||
wait_for_socket(&sock_path).await;
|
||||
|
||||
// Connect a client
|
||||
let stream = UnixStream::connect(&sock_path).await.unwrap();
|
||||
let (mut reader, mut writer) = tokio::io::split(stream);
|
||||
|
||||
@@ -520,18 +486,14 @@ async fn test_server_exits_when_all_clients_disconnect() {
|
||||
.unwrap();
|
||||
let _: ServerMessage = read_message(&mut reader).await.unwrap();
|
||||
|
||||
// Disconnect
|
||||
write_message(&mut writer, &ClientMessage::Disconnect)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Server should shut down on its own (all clients disconnected)
|
||||
// We can verify by checking the cancel token is cancelled or socket is removed
|
||||
// The server exits on its own once all clients disconnect; this just
|
||||
// confirms the test completes without hanging.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Socket should be cleaned up
|
||||
// Note: The server exits when all clients disconnect, so socket may be removed
|
||||
// We just verify the test completes without hanging
|
||||
handle.cancel.cancel();
|
||||
}
|
||||
|
||||
@@ -578,7 +540,8 @@ async fn test_runtime_profile_start_status_stop_across_clients() {
|
||||
client_a.cancel();
|
||||
client_b.cancel();
|
||||
handle.cancel.cancel();
|
||||
return; // pprof can't start in sandbox — skip
|
||||
// pprof can't start in sandbox — skip
|
||||
return;
|
||||
};
|
||||
assert!(matches!(
|
||||
started,
|
||||
@@ -723,7 +686,8 @@ async fn test_runtime_profile_creates_missing_parent_directory_end_to_end() {
|
||||
let Ok(started) = start_result else {
|
||||
client.cancel();
|
||||
handle.cancel.cancel();
|
||||
return; // pprof can't start in sandbox — skip
|
||||
// pprof can't start in sandbox — skip
|
||||
return;
|
||||
};
|
||||
assert!(matches!(
|
||||
started,
|
||||
@@ -811,16 +775,13 @@ async fn test_session_based_routing() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send a message with sessionId in params
|
||||
let test_message =
|
||||
r#"{"jsonrpc":"2.0","method":"session/start","id":1,"params":{"sessionId":"session-123"}}"#;
|
||||
client.send(test_message.to_string()).unwrap();
|
||||
|
||||
// Server receives the message
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
|
||||
// Verify session_id is in params
|
||||
assert_eq!(json["params"]["sessionId"], "session-123");
|
||||
|
||||
// Send a response with session_id for routing (without using request ID)
|
||||
@@ -857,25 +818,21 @@ async fn test_stdio_client_receives_tool_result() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send a simulated tool call request (e.g., read_file invocation)
|
||||
let test_tool_call = r#"{"jsonrpc":"2.0","method":"tool/call","id":100,"params":{"name":"read_file","arguments":{"target_file":"/path/to/test.txt"}}}"#;
|
||||
client.send(test_tool_call.to_string()).unwrap();
|
||||
|
||||
// Server should receive the tool call (with namespaced ID)
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
assert_eq!(json["method"], "tool/call");
|
||||
assert_eq!(json["params"]["name"], "read_file");
|
||||
let namespaced_id = json["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Simulate tool result response from agent (e.g., read_file success)
|
||||
let tool_result = format!(
|
||||
r#"{{"jsonrpc":"2.0","result":{{"content":"test file content"}},"id":"{}"}}"#,
|
||||
namespaced_id
|
||||
);
|
||||
response_tx.send(tool_result).unwrap();
|
||||
|
||||
// Client should receive the tool result with original ID restored
|
||||
let client_response = tokio::time::timeout(Duration::from_secs(2), client.recv())
|
||||
.await
|
||||
.expect("timeout waiting for tool result")
|
||||
@@ -897,7 +854,6 @@ async fn test_session_new_without_model_id_no_default() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Connect with yolo_mode but no default_model (typical VS Code extension setup)
|
||||
let client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"vscode-ext",
|
||||
@@ -915,12 +871,10 @@ async fn test_session_new_without_model_id_no_default() {
|
||||
let session_new = r#"{"jsonrpc":"2.0","id":31,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[],"_meta":{"yoloMode":true}}}"#;
|
||||
client.send(session_new.to_string()).unwrap();
|
||||
|
||||
// Server should forward it without injecting modelId
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
|
||||
assert_eq!(json["method"], "session/new");
|
||||
// _meta should have yoloMode but NOT modelId
|
||||
let meta = &json["params"]["_meta"];
|
||||
assert_eq!(meta["yoloMode"], true);
|
||||
assert!(
|
||||
@@ -939,7 +893,6 @@ async fn test_session_new_yolo_mode_no_model() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Client with yolo_mode=true but no default model
|
||||
let client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"vscode-ext",
|
||||
@@ -953,17 +906,14 @@ async fn test_session_new_yolo_mode_no_model() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send session/new without modelId or yoloMode in _meta
|
||||
let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#;
|
||||
client.send(session_new.to_string()).unwrap();
|
||||
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
|
||||
// yoloMode should be injected from capabilities
|
||||
let meta = &json["params"]["_meta"];
|
||||
assert_eq!(meta["yoloMode"], true);
|
||||
// modelId should NOT be present
|
||||
assert!(
|
||||
meta.get("modelId").is_none(),
|
||||
"modelId should not be injected when default_model is None"
|
||||
@@ -979,7 +929,6 @@ async fn test_session_new_empty_default_model_not_injected() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Client with empty string default_model (edge case from config)
|
||||
let client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"vscode-ext",
|
||||
@@ -999,10 +948,8 @@ async fn test_session_new_empty_default_model_not_injected() {
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
|
||||
// yoloMode should be injected
|
||||
let meta = &json["params"]["_meta"];
|
||||
assert_eq!(meta["yoloMode"], true);
|
||||
// Empty modelId should NOT be injected
|
||||
assert!(
|
||||
meta.get("modelId").is_none(),
|
||||
"empty default_model should not be injected as modelId"
|
||||
@@ -1037,7 +984,6 @@ async fn test_session_new_valid_default_model_injected() {
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
|
||||
// modelId should be injected from default_model
|
||||
let meta = &json["params"]["_meta"];
|
||||
assert_eq!(meta["modelId"], "kigi-3-fast");
|
||||
|
||||
@@ -1045,7 +991,7 @@ async fn test_session_new_valid_default_model_injected() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Session ownership & notification routing ──────────────────────────
|
||||
// Session ownership & notification routing
|
||||
|
||||
/// Test that the leader tracks session ownership from session/new responses
|
||||
/// and routes subsequent notifications (which have no request ID) to the
|
||||
@@ -1064,30 +1010,27 @@ async fn test_session_ownership_from_response_routes_notifications() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client sends session/new
|
||||
let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#;
|
||||
client.send(session_new.to_string()).unwrap();
|
||||
|
||||
// Server receives the request (with namespaced ID)
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
let namespaced_id = json["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Simulate agent response with sessionId in result
|
||||
let response = format!(
|
||||
r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-abc-123"}},"id":"{}"}}"#,
|
||||
namespaced_id
|
||||
);
|
||||
response_tx.send(response).unwrap();
|
||||
|
||||
// Client receives the session/new response
|
||||
let client_response = tokio::time::timeout(Duration::from_secs(2), client.recv())
|
||||
.await
|
||||
.expect("timeout")
|
||||
.expect("closed");
|
||||
let resp_json: serde_json::Value = serde_json::from_str(&client_response).unwrap();
|
||||
assert_eq!(resp_json["result"]["sessionId"], "sess-abc-123");
|
||||
assert_eq!(resp_json["id"], 1); // original ID restored
|
||||
// original ID restored
|
||||
assert_eq!(resp_json["id"], 1);
|
||||
|
||||
// Now send a notification for this session (no id field, only sessionId in params)
|
||||
// This tests session-based routing — the leader must know which client owns sess-abc-123
|
||||
@@ -1107,7 +1050,7 @@ async fn test_session_ownership_from_response_routes_notifications() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Multi-client session isolation ────────────────────────────────────
|
||||
// Multi-client session isolation
|
||||
|
||||
/// Test that two clients with different sessions receive only their own
|
||||
/// notifications, not each other's. This is critical for VS Code extension
|
||||
@@ -1117,7 +1060,6 @@ async fn test_two_clients_session_isolation() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Connect two clients (simulating two VS Code windows)
|
||||
let mut client1 = LeaderClient::connect(
|
||||
sock_path.clone(),
|
||||
"vscode-1",
|
||||
@@ -1135,7 +1077,6 @@ async fn test_two_clients_session_isolation() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client 1 creates session A
|
||||
client1
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/project-a","mcpServers":[]}}"#.to_string())
|
||||
.unwrap();
|
||||
@@ -1143,7 +1084,6 @@ async fn test_two_clients_session_isolation() {
|
||||
let json1: serde_json::Value = serde_json::from_str(&msg1).unwrap();
|
||||
let id1 = json1["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Client 2 creates session B
|
||||
client2
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/project-b","mcpServers":[]}}"#.to_string())
|
||||
.unwrap();
|
||||
@@ -1151,10 +1091,8 @@ async fn test_two_clients_session_isolation() {
|
||||
let json2: serde_json::Value = serde_json::from_str(&msg2).unwrap();
|
||||
let id2 = json2["id"].as_str().unwrap().to_string();
|
||||
|
||||
// IDs should be different (different client prefixes, same original ID)
|
||||
assert_ne!(id1, id2);
|
||||
|
||||
// Respond with different session IDs
|
||||
response_tx
|
||||
.send(format!(
|
||||
r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-AAA"}},"id":"{}"}}"#,
|
||||
@@ -1168,7 +1106,6 @@ async fn test_two_clients_session_isolation() {
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Each client should receive their own response
|
||||
let resp1 = tokio::time::timeout(Duration::from_secs(2), client1.recv())
|
||||
.await
|
||||
.expect("timeout")
|
||||
@@ -1183,7 +1120,6 @@ async fn test_two_clients_session_isolation() {
|
||||
assert_eq!(r1["result"]["sessionId"], "sess-AAA");
|
||||
assert_eq!(r2["result"]["sessionId"], "sess-BBB");
|
||||
|
||||
// Now send a notification for session A — only client 1 should get it
|
||||
let notif_a = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-AAA","data":"for-client-1"}}"#;
|
||||
response_tx.send(notif_a.to_string()).unwrap();
|
||||
|
||||
@@ -1195,7 +1131,6 @@ async fn test_two_clients_session_isolation() {
|
||||
assert_eq!(n1["params"]["sessionId"], "sess-AAA");
|
||||
assert_eq!(n1["params"]["data"], "for-client-1");
|
||||
|
||||
// Send a notification for session B — only client 2 should get it
|
||||
let notif_b = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-BBB","data":"for-client-2"}}"#;
|
||||
response_tx.send(notif_b.to_string()).unwrap();
|
||||
|
||||
@@ -1212,7 +1147,7 @@ async fn test_two_clients_session_isolation() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Multi-client model switch fan-out ─────────────────────────────────
|
||||
// Multi-client model switch fan-out
|
||||
|
||||
/// Multi-client model switch: when one TUI client switches models on a
|
||||
/// session shared with another TUI client, the leader must fan the
|
||||
@@ -1238,7 +1173,6 @@ async fn test_set_model_broadcasts_to_session_subscribers() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Two TUIs connected to the same leader, sharing one session.
|
||||
let mut invoker = LeaderClient::connect(
|
||||
sock_path.clone(),
|
||||
"kigi-tui-A",
|
||||
@@ -1286,7 +1220,6 @@ async fn test_set_model_broadcasts_to_session_subscribers() {
|
||||
.expect("timeout draining subscribe 2")
|
||||
.expect("closed");
|
||||
|
||||
// Invoker sends `session/setModel` for the shared session.
|
||||
invoker
|
||||
.send(format!(
|
||||
r#"{{"jsonrpc":"2.0","id":42,"method":"session/setModel","params":{{"sessionId":"{}","modelId":"kigi-4"}}}}"#,
|
||||
@@ -1387,7 +1320,7 @@ async fn test_set_model_broadcasts_to_session_subscribers() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Capability injection scope ────────────────────────────────────────
|
||||
// Capability injection scope
|
||||
|
||||
/// Test that capability injection ONLY applies to session/new, NOT to other
|
||||
/// methods like session/prompt or session/load. The leader must not mutate
|
||||
@@ -1410,28 +1343,23 @@ async fn test_capabilities_not_injected_into_non_session_new() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send session/prompt — capabilities should NOT be injected
|
||||
let prompt = r#"{"jsonrpc":"2.0","id":10,"method":"session/prompt","params":{"sessionId":"sess-123","prompt":{"content":"hello"}}}"#;
|
||||
client.send(prompt.to_string()).unwrap();
|
||||
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
|
||||
// The params should NOT have _meta.yoloMode or _meta.modelId injected
|
||||
assert!(
|
||||
json["params"].get("_meta").is_none(),
|
||||
"session/prompt should not have _meta injected"
|
||||
);
|
||||
// Original params should be preserved
|
||||
assert_eq!(json["params"]["sessionId"], "sess-123");
|
||||
|
||||
// Send session/load — should get clientIdentifier but NOT yoloMode/modelId
|
||||
let load = r#"{"jsonrpc":"2.0","id":11,"method":"session/load","params":{"sessionId":"sess-456","cwd":"/tmp","mcpServers":[]}}"#;
|
||||
client.send(load.to_string()).unwrap();
|
||||
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
// session/load should NOT get yoloMode or modelId injected
|
||||
assert!(
|
||||
json["params"]["_meta"].get("yoloMode").is_none(),
|
||||
"session/load should not have yoloMode injected"
|
||||
@@ -1453,7 +1381,6 @@ async fn test_yolo_mode_injection_preserves_explicit_false() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Client registered with yolo_mode=true
|
||||
let client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"vscode-ext",
|
||||
@@ -1467,7 +1394,6 @@ async fn test_yolo_mode_injection_preserves_explicit_false() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Request explicitly sets yoloMode to false
|
||||
let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[],"_meta":{"yoloMode":false}}}"#;
|
||||
client.send(session_new.to_string()).unwrap();
|
||||
|
||||
@@ -1481,7 +1407,7 @@ async fn test_yolo_mode_injection_preserves_explicit_false() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Notification (no ID) pass-through ─────────────────────────────────
|
||||
// Notification (no ID) pass-through
|
||||
|
||||
/// Test that JSON-RPC notifications (no "id" field) sent by a client are
|
||||
/// forwarded without ID rewriting. Notifications include cancel, yolo_mode_changed, etc.
|
||||
@@ -1499,14 +1425,12 @@ async fn test_client_notification_forwarded_without_id_rewrite() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send a cancel notification (no "id" field)
|
||||
let cancel_notif = r#"{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess-123","reason":"user"}}"#;
|
||||
client.send(cancel_notif.to_string()).unwrap();
|
||||
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
|
||||
// Should not have an "id" field added
|
||||
assert!(
|
||||
json.get("id").is_none(),
|
||||
"notifications should not get an id"
|
||||
@@ -1592,7 +1516,7 @@ async fn test_cancel_prompt_id_meta_passes_through_with_two_clients() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Extension method routing ──────────────────────────────────────────
|
||||
// Extension method routing
|
||||
|
||||
/// Test that extension methods (prefixed with _) are correctly forwarded
|
||||
/// through the leader with proper ID namespacing and response routing.
|
||||
@@ -1610,20 +1534,17 @@ async fn test_extension_method_roundtrip() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send an extension method call (e.g., fuzzy search open)
|
||||
let ext_call = r#"{"jsonrpc":"2.0","id":50,"method":"_kigi/search/fuzzy/open","params":{"sessionId":"sess-123","hidden":false}}"#;
|
||||
client.send(ext_call.to_string()).unwrap();
|
||||
|
||||
let received = acp_rx.recv().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
|
||||
// Method should be preserved, ID should be namespaced
|
||||
assert_eq!(json["method"], "_kigi/search/fuzzy/open");
|
||||
let namespaced_id = json["id"].as_str().unwrap();
|
||||
assert!(namespaced_id.contains(ID_NAMESPACE_SEP));
|
||||
assert!(namespaced_id.ends_with("|50"));
|
||||
|
||||
// Simulate response
|
||||
let response = format!(
|
||||
r#"{{"jsonrpc":"2.0","result":{{"searchId":"search-xyz"}},"id":"{}"}}"#,
|
||||
namespaced_id
|
||||
@@ -1636,7 +1557,6 @@ async fn test_extension_method_roundtrip() {
|
||||
.expect("closed");
|
||||
let resp_json: serde_json::Value = serde_json::from_str(&client_response).unwrap();
|
||||
|
||||
// Original ID should be restored, result preserved
|
||||
assert_eq!(resp_json["id"], 50);
|
||||
assert_eq!(resp_json["result"]["searchId"], "search-xyz");
|
||||
|
||||
@@ -1644,7 +1564,7 @@ async fn test_extension_method_roundtrip() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Error response routing ────────────────────────────────────────────
|
||||
// Error response routing
|
||||
|
||||
/// Test that JSON-RPC error responses from the agent are correctly routed
|
||||
/// back to the requesting client with the original ID restored.
|
||||
@@ -1662,7 +1582,6 @@ async fn test_error_response_routing() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send a request
|
||||
client
|
||||
.send(
|
||||
r#"{"jsonrpc":"2.0","id":99,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#
|
||||
@@ -1674,7 +1593,6 @@ async fn test_error_response_routing() {
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
let namespaced_id = json["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Simulate an error response (e.g., auth_required)
|
||||
let error_response = format!(
|
||||
r#"{{"jsonrpc":"2.0","error":{{"code":-32001,"message":"auth_required","data":"No credentials"}},"id":"{}"}}"#,
|
||||
namespaced_id
|
||||
@@ -1687,7 +1605,6 @@ async fn test_error_response_routing() {
|
||||
.expect("closed");
|
||||
let resp_json: serde_json::Value = serde_json::from_str(&client_response).unwrap();
|
||||
|
||||
// Original ID restored, error preserved
|
||||
assert_eq!(resp_json["id"], 99);
|
||||
assert_eq!(resp_json["error"]["code"], -32001);
|
||||
assert_eq!(resp_json["error"]["message"], "auth_required");
|
||||
@@ -1697,7 +1614,7 @@ async fn test_error_response_routing() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Session cleanup on disconnect ─────────────────────────────────────
|
||||
// Session cleanup on disconnect
|
||||
|
||||
/// Test that when a client disconnects, notifications for its sessions are
|
||||
/// still delivered to the next active client via fallback routing.
|
||||
@@ -1754,7 +1671,6 @@ async fn test_session_ownership_cleanup_on_disconnect() {
|
||||
|
||||
wait_for_socket(&sock_path).await;
|
||||
|
||||
// Connect client, create session, then disconnect
|
||||
{
|
||||
let mut client = LeaderClient::connect(
|
||||
sock_path.clone(),
|
||||
@@ -1765,7 +1681,6 @@ async fn test_session_ownership_cleanup_on_disconnect() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create session
|
||||
client
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#.to_string())
|
||||
.unwrap();
|
||||
@@ -1773,7 +1688,6 @@ async fn test_session_ownership_cleanup_on_disconnect() {
|
||||
let json: serde_json::Value = serde_json::from_str(&received).unwrap();
|
||||
let namespaced_id = json["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Respond with session ID
|
||||
response_tx
|
||||
.send(format!(
|
||||
r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-temp"}},"id":"{}"}}"#,
|
||||
@@ -1783,7 +1697,6 @@ async fn test_session_ownership_cleanup_on_disconnect() {
|
||||
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), client.recv()).await;
|
||||
|
||||
// Client disconnects (dropped)
|
||||
client.cancel();
|
||||
}
|
||||
|
||||
@@ -1797,7 +1710,6 @@ async fn test_session_ownership_cleanup_on_disconnect() {
|
||||
let eviction_json: serde_json::Value = serde_json::from_str(&eviction).unwrap();
|
||||
assert_eq!(eviction_json["method"], "kigi/internal/evict_sessions");
|
||||
|
||||
// Connect a NEW client — server should still be running
|
||||
let mut client2 = LeaderClient::connect(
|
||||
sock_path,
|
||||
"vscode-new",
|
||||
@@ -1807,7 +1719,6 @@ async fn test_session_ownership_cleanup_on_disconnect() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Make client2 active (so it becomes fallback)
|
||||
client2
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#.to_string())
|
||||
.unwrap();
|
||||
@@ -1839,13 +1750,10 @@ async fn test_session_ownership_cleanup_on_disconnect() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Code-nav capability injection integration tests
|
||||
//
|
||||
// These tests exercise the full leader→agent injection pipeline for the
|
||||
// `code_nav_enabled` capability, verifying that per-client isolation is
|
||||
// correct from the leader boundary all the way to the forwarded ACP payload.
|
||||
// =============================================================================
|
||||
// Code-nav capability injection integration tests exercise the full
|
||||
// leader→agent injection pipeline for the `code_nav_enabled` capability,
|
||||
// verifying that per-client isolation is correct from the leader boundary
|
||||
// all the way to the forwarded ACP payload.
|
||||
|
||||
/// Verify that the leader injects `codeNavEnabled: true` into session/new for
|
||||
/// a web client that registered with `code_nav_enabled: true`.
|
||||
@@ -1854,7 +1762,6 @@ async fn test_code_nav_capable_client_gets_true_injected_into_session_new() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Web client that advertised code-nav capability during registration.
|
||||
let web_client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"kigi-web",
|
||||
@@ -1891,7 +1798,6 @@ async fn test_non_code_nav_client_gets_false_injected_into_session_new() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// TUI client with no code-nav capability.
|
||||
let tui_client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"kigi-tui",
|
||||
@@ -1930,7 +1836,6 @@ async fn test_leader_code_nav_client_isolation() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Web client with code-nav capability.
|
||||
let web_client = LeaderClient::connect(
|
||||
sock_path.clone(),
|
||||
"kigi-web",
|
||||
@@ -1943,7 +1848,6 @@ async fn test_leader_code_nav_client_isolation() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// TUI client without code-nav capability.
|
||||
let tui_client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"kigi-tui",
|
||||
@@ -1958,12 +1862,10 @@ async fn test_leader_code_nav_client_isolation() {
|
||||
|
||||
let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/repo","mcpServers":[]}}"#;
|
||||
|
||||
// Web client sends session/new first.
|
||||
web_client.send(session_new.to_string()).unwrap();
|
||||
let web_fwd = acp_rx.recv().await.unwrap();
|
||||
let web_json: serde_json::Value = serde_json::from_str(&web_fwd).unwrap();
|
||||
|
||||
// TUI client sends session/new second.
|
||||
tui_client.send(session_new.to_string()).unwrap();
|
||||
let tui_fwd = acp_rx.recv().await.unwrap();
|
||||
let tui_json: serde_json::Value = serde_json::from_str(&tui_fwd).unwrap();
|
||||
@@ -2043,7 +1945,6 @@ async fn test_code_status_ext_request_forwarded_to_agent() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send kigi/code/status with a sessionId — the leader must forward it to the agent.
|
||||
let status_req = r#"{"jsonrpc":"2.0","id":42,"method":"extensions/ext","params":{"method":"kigi/code/status","params":{"sessionId":"sess-web-1","cwd":"/repo"}}}"#;
|
||||
web_client.send(status_req.to_string()).unwrap();
|
||||
|
||||
@@ -2059,7 +1960,7 @@ async fn test_code_status_ext_request_forwarded_to_agent() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Startup readiness gate ────────────────────────────────────────────
|
||||
// Startup readiness gate
|
||||
|
||||
/// Raw-protocol test for the server-side readiness handshake.
|
||||
///
|
||||
@@ -2086,7 +1987,8 @@ async fn test_raw_registration_handshake_not_ready_then_ready() {
|
||||
let (acp_tx, mut acp_rx) = mpsc::unbounded_channel::<String>();
|
||||
let (response_tx, response_rx) = mpsc::unbounded_channel::<String>();
|
||||
let cancel = CancellationToken::new();
|
||||
let (ready_tx, ready_rx) = watch::channel(false); // NOT ready yet
|
||||
// NOT ready yet
|
||||
let (ready_tx, ready_rx) = watch::channel(false);
|
||||
|
||||
let sock_clone = sock_path.clone();
|
||||
let cancel_clone = cancel.clone();
|
||||
@@ -2117,11 +2019,9 @@ async fn test_raw_registration_handshake_not_ready_then_ready() {
|
||||
|
||||
wait_for_socket(&sock_path).await;
|
||||
|
||||
// Connect via raw socket to observe the wire-level handshake.
|
||||
let stream = UnixStream::connect(&sock_path).await.unwrap();
|
||||
let (mut reader, mut writer) = tokio::io::split(stream);
|
||||
|
||||
// Register manually.
|
||||
write_message(
|
||||
&mut writer,
|
||||
&ClientMessage::Register {
|
||||
@@ -2133,7 +2033,7 @@ async fn test_raw_registration_handshake_not_ready_then_ready() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// ── Server must respond Registered { ready: false } ───────────────────────
|
||||
// Server must respond Registered { ready: false }
|
||||
let reg_msg: ServerMessage =
|
||||
tokio::time::timeout(Duration::from_secs(2), read_message(&mut reader))
|
||||
.await
|
||||
@@ -2154,12 +2054,12 @@ async fn test_raw_registration_handshake_not_ready_then_ready() {
|
||||
other => panic!("Expected Registered, got {other:?}"),
|
||||
}
|
||||
|
||||
// ── Signal readiness (simulates auth + prefetch completing) ───────────────
|
||||
// Signal readiness (simulates auth + prefetch completing)
|
||||
// The server's per-client session is now blocked in its readiness wait loop.
|
||||
// Signalling here causes it to send LeaderReady to this client.
|
||||
ready_tx.send(true).unwrap();
|
||||
|
||||
// ── Server must now send LeaderReady ──────────────────────────────────────
|
||||
// Server must now send LeaderReady
|
||||
let ready_msg: ServerMessage =
|
||||
tokio::time::timeout(Duration::from_secs(2), read_message(&mut reader))
|
||||
.await
|
||||
@@ -2171,7 +2071,7 @@ async fn test_raw_registration_handshake_not_ready_then_ready() {
|
||||
"Expected LeaderReady, got {ready_msg:?}"
|
||||
);
|
||||
|
||||
// ── Post-ready: ACP flows normally ────────────────────────────────────────
|
||||
// Post-ready: ACP flows normally
|
||||
write_message(
|
||||
&mut writer,
|
||||
&ClientMessage::Acp {
|
||||
@@ -2190,7 +2090,6 @@ async fn test_raw_registration_handshake_not_ready_then_ready() {
|
||||
assert_eq!(fwd["method"], "session/new");
|
||||
let namespaced_id = fwd["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Round-trip the response.
|
||||
response_tx
|
||||
.send(format!(
|
||||
r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-ok"}},"id":"{namespaced_id}"}}"#
|
||||
@@ -2240,7 +2139,8 @@ async fn test_connect_waits_for_leader_ready() {
|
||||
let (acp_tx, mut acp_rx) = mpsc::unbounded_channel::<String>();
|
||||
let (response_tx, response_rx) = mpsc::unbounded_channel::<String>();
|
||||
let cancel = CancellationToken::new();
|
||||
let (ready_tx, ready_rx) = watch::channel(false); // NOT ready yet
|
||||
// NOT ready yet
|
||||
let (ready_tx, ready_rx) = watch::channel(false);
|
||||
|
||||
let sock_clone = sock_path.clone();
|
||||
let cancel_clone = cancel.clone();
|
||||
@@ -2271,7 +2171,6 @@ async fn test_connect_waits_for_leader_ready() {
|
||||
|
||||
wait_for_socket(&sock_path).await;
|
||||
|
||||
// Spawn a task to signal readiness after a short delay (simulating slow auth/prefetch).
|
||||
let ready_delay_ms = 150u64;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(ready_delay_ms)).await;
|
||||
@@ -2292,7 +2191,6 @@ async fn test_connect_waits_for_leader_ready() {
|
||||
.expect("connect must succeed after leader becomes ready");
|
||||
let elapsed = connect_start.elapsed();
|
||||
|
||||
// Connect must have waited at least as long as the readiness delay.
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(ready_delay_ms.saturating_sub(30)),
|
||||
"connect returned too early ({elapsed:?}); should have waited for LeaderReady"
|
||||
@@ -2315,7 +2213,6 @@ async fn test_connect_waits_for_leader_ready() {
|
||||
"initialize must reach the agent, not be rejected with leader_starting"
|
||||
);
|
||||
|
||||
// Verify full round-trip.
|
||||
let namespaced_id = fwd_json["id"].as_str().unwrap().to_string();
|
||||
response_tx
|
||||
.send(format!(
|
||||
@@ -2335,7 +2232,7 @@ async fn test_connect_waits_for_leader_ready() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Version mismatch notification ────────────────────────────────────
|
||||
// Version mismatch notification
|
||||
|
||||
/// Integration test: a connected client receives `kigi/leader/version_mismatch`
|
||||
/// when its `client_version` differs from the leader's version.
|
||||
@@ -2378,7 +2275,8 @@ async fn test_version_mismatch_notification_sent_to_client() {
|
||||
kigi_shell::agent::activity::AgentActivity::default(),
|
||||
watch::channel(true).1,
|
||||
watch::channel(kigi_shell::leader::ShutdownReason::Manual).0,
|
||||
Some("test-leader-0.1.150"), // override so detection is enabled in test builds
|
||||
// override so detection is enabled in test builds
|
||||
Some("test-leader-0.1.150"),
|
||||
control_state,
|
||||
)
|
||||
.await;
|
||||
@@ -2386,7 +2284,6 @@ async fn test_version_mismatch_notification_sent_to_client() {
|
||||
|
||||
wait_for_socket(&sock_path).await;
|
||||
|
||||
// Connect with a version that differs from the leader override.
|
||||
let mut client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"test-client",
|
||||
@@ -2399,7 +2296,6 @@ async fn test_version_mismatch_notification_sent_to_client() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The client should receive the version mismatch notification.
|
||||
let msg = tokio::time::timeout(Duration::from_secs(2), client.recv())
|
||||
.await
|
||||
.expect("timeout waiting for version mismatch notification")
|
||||
@@ -2459,7 +2355,6 @@ async fn test_no_version_mismatch_notification_when_versions_match() {
|
||||
|
||||
wait_for_socket(&sock_path).await;
|
||||
|
||||
// Connect with the same version as the leader.
|
||||
let mut client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"test-client",
|
||||
@@ -2472,7 +2367,6 @@ async fn test_no_version_mismatch_notification_when_versions_match() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// No mismatch notification should arrive within a short window.
|
||||
let received = tokio::time::timeout(Duration::from_millis(200), client.recv()).await;
|
||||
assert!(
|
||||
received.is_err(),
|
||||
@@ -2483,7 +2377,7 @@ async fn test_no_version_mismatch_notification_when_versions_match() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── Shutdown reason end-to-end ────────────────────────────────────────
|
||||
// Shutdown reason end-to-end
|
||||
|
||||
/// End-to-end test: a connected `LeaderClient` receives `ShuttingDown { reason: AutoUpdate }`
|
||||
/// and `LeaderClient::shutting_down_reason()` updates to `Some(AutoUpdate)`.
|
||||
@@ -2552,7 +2446,7 @@ async fn test_auto_update_shutdown_reason_reaches_client() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Disruptive relaunch-for-update ────────────────────────────────────
|
||||
// Disruptive relaunch-for-update
|
||||
|
||||
/// A `RelaunchForUpdate` with a strictly-newer target is accepted with a
|
||||
/// `Relaunching` ack, and the leader then broadcasts `ShuttingDown { AutoUpdate }`
|
||||
@@ -2713,15 +2607,16 @@ async fn test_relaunch_for_update_waits_for_busy_then_exits() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── initialize_seen correctness ───────────────────────────────────────
|
||||
// initialize_seen correctness
|
||||
|
||||
/// Regression test: if the first ACP message is NOT `initialize`, a later
|
||||
/// `initialize` must still receive `clientIdentifier` injection.
|
||||
///
|
||||
/// Previously, `identity_injected` was set to `true` after the first ACP
|
||||
/// message regardless of its method, so a client whose first message was a
|
||||
/// notification (e.g., `session/cancel`) would never have `clientIdentifier`
|
||||
/// injected into the real `initialize` that followed.
|
||||
/// `identity_injected` must only flip to `true` on an actual `initialize`
|
||||
/// message — flipping it on the first ACP message regardless of method would
|
||||
/// leave a client whose first message is a notification (e.g.,
|
||||
/// `session/cancel`) without `clientIdentifier` injected into the real
|
||||
/// `initialize` that follows.
|
||||
#[tokio::test]
|
||||
async fn test_initialize_injected_when_not_first_message() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -2779,7 +2674,6 @@ async fn test_leader_code_nav_isolation_end_to_end() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await;
|
||||
|
||||
// Web client with code-nav capability.
|
||||
let web_client = LeaderClient::connect(
|
||||
sock_path.clone(),
|
||||
"kigi-web",
|
||||
@@ -2792,7 +2686,6 @@ async fn test_leader_code_nav_isolation_end_to_end() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// TUI client without code-nav capability.
|
||||
let tui_client = LeaderClient::connect(
|
||||
sock_path,
|
||||
"kigi-tui",
|
||||
@@ -2807,7 +2700,6 @@ async fn test_leader_code_nav_isolation_end_to_end() {
|
||||
|
||||
let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/repo","mcpServers":[]}}"#;
|
||||
|
||||
// Both clients send session/new; verify independent codeNavEnabled injection.
|
||||
web_client.send(session_new.to_string()).unwrap();
|
||||
let web_fwd = acp_rx.recv().await.unwrap();
|
||||
let web_json: serde_json::Value = serde_json::from_str(&web_fwd).unwrap();
|
||||
@@ -2824,7 +2716,6 @@ async fn test_leader_code_nav_isolation_end_to_end() {
|
||||
serde_json::json!(false)
|
||||
);
|
||||
|
||||
// Web client sends kigi/code/status (the primary non-starting code-nav call).
|
||||
let status_with_session = r#"{"jsonrpc":"2.0","id":10,"method":"extensions/ext","params":{"method":"kigi/code/status","params":{"sessionId":"web-session","cwd":"/repo"}}}"#;
|
||||
web_client.send(status_with_session.to_string()).unwrap();
|
||||
|
||||
@@ -2860,7 +2751,6 @@ async fn test_lock_released_before_connect_prevents_deadlock() {
|
||||
let sock_path = temp.path().join("leader.sock");
|
||||
let lock_path = temp.path().join("leader.lock");
|
||||
|
||||
// Spawner acquires the file lock (mirrors connect_or_spawn).
|
||||
let lock_file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
@@ -2934,14 +2824,11 @@ async fn test_lock_released_before_connect_prevents_deadlock() {
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hung-agent + sever-mid-RPC scenarios
|
||||
//
|
||||
// The fake agent in these tests is the test body itself (acp_rx/response_tx),
|
||||
// so "agent hangs" and "agent completes after the client is gone" are driven
|
||||
// deterministically. Reconnects use fresh raw `UnixStream`s so the sever is
|
||||
// an abrupt socket close, not a graceful `Disconnect`.
|
||||
// =============================================================================
|
||||
// Hung-agent + sever-mid-RPC scenarios: the fake agent in these tests is the
|
||||
// test body itself (acp_rx/response_tx), so "agent hangs" and "agent
|
||||
// completes after the client is gone" are driven deterministically.
|
||||
// Reconnects use fresh raw `UnixStream`s so the sever is an abrupt socket
|
||||
// close, not a graceful `Disconnect`.
|
||||
|
||||
/// Server that survives client disconnects (`no_exit_on_disconnect = true`),
|
||||
/// for sever/reconnect scenarios. Same wiring as
|
||||
|
||||
@@ -190,7 +190,6 @@ mod mcp_apps_tests {
|
||||
fn test_meta_ui_survives_serialization_roundtrip() {
|
||||
// rmcp Meta is #[serde(transparent)] over JsonObject.
|
||||
// Our pipeline does: tool.meta → serde_json::to_value → Option<Value>.
|
||||
// Verify the ui.resourceUri survives this conversion.
|
||||
let tool = ui_tool("dashboard", "ui://server/dash", None);
|
||||
let meta_value: serde_json::Value =
|
||||
serde_json::to_value(tool.meta.as_ref().unwrap()).unwrap();
|
||||
@@ -249,7 +248,6 @@ mod unit_tests {
|
||||
let converted_schema = serde_json::to_value(rmcp_tool.input_schema.as_ref())
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
|
||||
// Verify the required field is preserved
|
||||
assert_eq!(converted_schema["type"], "object");
|
||||
assert_eq!(converted_schema["required"], json!(["url"]));
|
||||
assert!(converted_schema["properties"]["url"].is_object());
|
||||
@@ -270,7 +268,6 @@ mod unit_tests {
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Build an rmcp Tool with a real schema (properties, required, etc.)
|
||||
let server_schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -295,7 +292,6 @@ mod unit_tests {
|
||||
obj.entry("type").or_insert_with(|| json!("object"));
|
||||
}
|
||||
|
||||
// The schema in the registration must be the MCP server's schema
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert_eq!(schema["required"], json!(["query"]));
|
||||
assert!(schema["properties"]["query"].is_object());
|
||||
|
||||
@@ -217,7 +217,8 @@ async fn run_actor_test_full<F, Fut>(
|
||||
cwd.clone(),
|
||||
client_type,
|
||||
policy,
|
||||
vec![], // deny_read_globs
|
||||
// deny_read_globs
|
||||
vec![],
|
||||
vec![],
|
||||
initial_yolo,
|
||||
None,
|
||||
@@ -237,8 +238,6 @@ fn rule(action: RuleAction, pattern: &str) -> PermissionRule {
|
||||
}
|
||||
}
|
||||
|
||||
// --- mcp_pre_decision-style end-to-end ---
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn mcp_tool_grant_persists_and_short_circuits_next_request() {
|
||||
@@ -364,11 +363,13 @@ async fn policy_ask_suppresses_mcp_tool_allowlist() {
|
||||
cwd.clone(),
|
||||
ClientType::KigiPager,
|
||||
Some(policy),
|
||||
vec![], // deny_read_globs
|
||||
// deny_read_globs
|
||||
vec![],
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
false, // remember_tool_approvals
|
||||
// remember_tool_approvals
|
||||
false,
|
||||
);
|
||||
|
||||
// Script an outright reject so we can confirm the prompt fires.
|
||||
@@ -414,11 +415,13 @@ async fn policy_ask_suppresses_mcp_server_allowlist() {
|
||||
cwd.clone(),
|
||||
ClientType::KigiPager,
|
||||
Some(policy),
|
||||
vec![], // deny_read_globs
|
||||
// deny_read_globs
|
||||
vec![],
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
false, // remember_tool_approvals
|
||||
// remember_tool_approvals
|
||||
false,
|
||||
);
|
||||
|
||||
gw.expected.send(("reject-once".to_string(), None)).unwrap();
|
||||
@@ -458,7 +461,8 @@ async fn policy_deny_takes_precedence_over_mcp_allowlist() {
|
||||
cwd.clone(),
|
||||
ClientType::KigiPager,
|
||||
Some(policy),
|
||||
vec![], // deny_read_globs
|
||||
// deny_read_globs
|
||||
vec![],
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
@@ -703,8 +707,6 @@ async fn dont_ask_policy_denies_without_prompting() {
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- deny rules survive YOLO mode ---
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn deny_rule_enforced_in_yolo_mode_bash() {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
//! `stop_reason: "refusal"` must complete the turn cleanly with EXACTLY ONE
|
||||
//! inference request.
|
||||
//!
|
||||
//! Previously the unknown `stop_reason` failed the terminal `message_delta`
|
||||
//! parse (discarding the fully-streamed response) and the resulting
|
||||
//! serialization error was misclassified as a retryable stream error,
|
||||
//! producing a ~10-minute retry storm per turn. Covered here end-to-end
|
||||
//! through both the plain stdio agent and a leader-hosted session.
|
||||
//! An unknown `stop_reason` fails the terminal `message_delta` parse
|
||||
//! (discarding the fully-streamed response); the resulting serialization
|
||||
//! error is misclassified as a retryable stream error, producing a
|
||||
//! ~10-minute retry storm per turn. Covered here end-to-end through both
|
||||
//! the plain stdio agent and a leader-hosted session.
|
||||
//!
|
||||
//! Tests are `#[ignore]`d by default — they require a pre-built binary
|
||||
//! (auto-built locally when missing):
|
||||
@@ -65,8 +65,9 @@ fn turn_messages_request_count(server: &MockInferenceServer) -> usize {
|
||||
|
||||
/// THE regression test: a refusal-terminated `/v1/messages` turn must return
|
||||
/// a successful prompt response from exactly one inference request.
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_refusal_turn_completes_with_single_messages_request() {
|
||||
with_local_set(|| async {
|
||||
let server = refusal_messages_server().await;
|
||||
@@ -111,11 +112,8 @@ async fn test_refusal_turn_completes_with_single_messages_request() {
|
||||
.await;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Leader mode: the same refusal scenario through a leader-hosted session
|
||||
// (client → stdio bridge → leader unix socket → leader-hosted agent).
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(unix)]
|
||||
mod leader {
|
||||
use std::time::Duration;
|
||||
@@ -130,8 +128,9 @@ mod leader {
|
||||
/// Leader-mode variant of the regression: the refusal-terminated turn
|
||||
/// must complete cleanly (single request, prompt response delivered)
|
||||
/// when the session is hosted by the leader IPC server.
|
||||
// requires pre-built binary; run with --ignored
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
#[ignore]
|
||||
async fn test_leader_refusal_turn_completes_with_single_messages_request() {
|
||||
with_local_set(|| async {
|
||||
let server = refusal_messages_server().await;
|
||||
|
||||
@@ -23,15 +23,9 @@ mod common;
|
||||
|
||||
use common::{create_test_client, create_test_client_with_extra_headers, test_sampler_config};
|
||||
|
||||
// ============================================================================
|
||||
// Mock Response Generators
|
||||
// ============================================================================
|
||||
|
||||
/// Generate a Chat Completions SSE stream with tool calls.
|
||||
fn chat_completion_tool_call_stream(tool_calls: Vec<Value>, model: &str) -> Vec<SseEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// First chunk with role
|
||||
let first_chunk = json!({
|
||||
"id": "chatcmpl-test123",
|
||||
"object": "chat.completion.chunk",
|
||||
@@ -49,7 +43,6 @@ fn chat_completion_tool_call_stream(tool_calls: Vec<Value>, model: &str) -> Vec<
|
||||
});
|
||||
events.push(SseEvent::data(first_chunk.to_string()));
|
||||
|
||||
// Final chunk with finish reason
|
||||
let final_chunk = json!({
|
||||
"id": "chatcmpl-test123",
|
||||
"object": "chat.completion.chunk",
|
||||
@@ -72,7 +65,6 @@ fn chat_completion_tool_call_stream(tool_calls: Vec<Value>, model: &str) -> Vec<
|
||||
events
|
||||
}
|
||||
|
||||
/// Generate a Chat Completions SSE stream with reasoning content.
|
||||
fn chat_completion_with_reasoning_stream(
|
||||
reasoning: &str,
|
||||
content: &str,
|
||||
@@ -80,7 +72,6 @@ fn chat_completion_with_reasoning_stream(
|
||||
) -> Vec<SseEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Reasoning chunks
|
||||
for word in reasoning.split_whitespace() {
|
||||
let chunk = json!({
|
||||
"id": "chatcmpl-test123",
|
||||
@@ -98,7 +89,6 @@ fn chat_completion_with_reasoning_stream(
|
||||
events.push(SseEvent::data(chunk.to_string()));
|
||||
}
|
||||
|
||||
// Content chunks
|
||||
for word in content.split_whitespace() {
|
||||
let chunk = json!({
|
||||
"id": "chatcmpl-test123",
|
||||
@@ -116,7 +106,6 @@ fn chat_completion_with_reasoning_stream(
|
||||
events.push(SseEvent::data(chunk.to_string()));
|
||||
}
|
||||
|
||||
// Final chunk
|
||||
let final_chunk = json!({
|
||||
"id": "chatcmpl-test123",
|
||||
"object": "chat.completion.chunk",
|
||||
@@ -139,7 +128,6 @@ fn chat_completion_with_reasoning_stream(
|
||||
events
|
||||
}
|
||||
|
||||
/// Generate a Responses API SSE stream with function calls.
|
||||
fn responses_api_tool_call_stream(
|
||||
call_id: &str,
|
||||
name: &str,
|
||||
@@ -149,7 +137,6 @@ fn responses_api_tool_call_stream(
|
||||
let mut events = Vec::new();
|
||||
let mut seq = 0;
|
||||
|
||||
// response.created event
|
||||
let created = json!({
|
||||
"type": "response.created",
|
||||
"sequence_number": seq,
|
||||
@@ -165,7 +152,6 @@ fn responses_api_tool_call_stream(
|
||||
events.push(SseEvent::data(created.to_string()));
|
||||
seq += 1;
|
||||
|
||||
// Function call arguments delta
|
||||
let args_delta = json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"sequence_number": seq,
|
||||
@@ -176,7 +162,6 @@ fn responses_api_tool_call_stream(
|
||||
events.push(SseEvent::data(args_delta.to_string()));
|
||||
seq += 1;
|
||||
|
||||
// response.completed event with function call
|
||||
let completed = json!({
|
||||
"type": "response.completed",
|
||||
"sequence_number": seq,
|
||||
@@ -211,10 +196,6 @@ fn responses_api_tool_call_stream(
|
||||
events
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Chat Completions API Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_streaming_text() {
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
@@ -325,10 +306,6 @@ async fn test_chat_completions_with_reasoning() {
|
||||
assert!(content.contains("42"));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reasoning-as-sibling — chat completions, both paths
|
||||
// ============================================================================
|
||||
|
||||
/// All-new path: a chat-completions stream carrying `reasoning_content`
|
||||
/// deltas must be collected into a sibling `ConversationItem::Reasoning`
|
||||
/// that *precedes* the assistant — the exact shape currently persisted to
|
||||
@@ -397,8 +374,8 @@ async fn chat_completions_collect_synthesizes_reasoning_sibling() {
|
||||
/// (conversation_to_chat_messages) → wire.
|
||||
#[tokio::test]
|
||||
async fn chat_completions_upgrade_folds_reconstructed_reasoning_into_request() {
|
||||
// 1. Seed a legacy chat-completions chat_history.jsonl (inline reasoning
|
||||
// on the assistant — the shape an older binary wrote).
|
||||
// Seed a legacy chat-completions chat_history.jsonl (inline reasoning
|
||||
// on the assistant — the shape an older binary wrote).
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("chat_history.jsonl"),
|
||||
@@ -413,7 +390,7 @@ async fn chat_completions_upgrade_folds_reconstructed_reasoning_into_request() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 2. Load through the real adapter — applies the in-memory upgrade.
|
||||
// Load through the real adapter — applies the in-memory upgrade.
|
||||
let adapter = JsonlStorageAdapter::with_root(dir.path().to_path_buf());
|
||||
let mut items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
|
||||
assert!(
|
||||
@@ -424,8 +401,8 @@ async fn chat_completions_upgrade_folds_reconstructed_reasoning_into_request() {
|
||||
items
|
||||
);
|
||||
|
||||
// 3. Continue the conversation and send it over chat-completions,
|
||||
// capturing the outgoing request body.
|
||||
// Continue the conversation and send it over chat-completions,
|
||||
// capturing the outgoing request body.
|
||||
items.push(ConversationItem::user("q2"));
|
||||
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
@@ -437,8 +414,8 @@ async fn chat_completions_upgrade_folds_reconstructed_reasoning_into_request() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 4. The reconstructed reasoning must land on the assistant's
|
||||
// reasoning_content in the wire request — not be dropped.
|
||||
// The reconstructed reasoning must land on the assistant's
|
||||
// reasoning_content in the wire request — not be dropped.
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
let messages = body.get("messages").unwrap().as_array().unwrap();
|
||||
let assistant = messages
|
||||
@@ -462,8 +439,8 @@ async fn chat_completions_upgrade_folds_reconstructed_reasoning_into_request() {
|
||||
/// through `reasoning_item_text`.
|
||||
#[tokio::test]
|
||||
async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
|
||||
// 1. Seed a legacy kigi chat_history.jsonl (inline reasoning
|
||||
// with encrypted_content + id — the older shape).
|
||||
// Seed a legacy kigi chat_history.jsonl (inline reasoning
|
||||
// with encrypted_content + id — the older shape).
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("chat_history.jsonl"),
|
||||
@@ -483,7 +460,7 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 2. Load through the real adapter — applies the upgrade.
|
||||
// Load through the real adapter — applies the upgrade.
|
||||
let adapter = JsonlStorageAdapter::with_root(dir.path().to_path_buf());
|
||||
let mut items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
|
||||
assert!(
|
||||
@@ -493,7 +470,7 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
|
||||
"legacy inline reasoning must be reconstructed as a sibling on load, got {items:?}"
|
||||
);
|
||||
|
||||
// 3. Continue and send over the Responses API, capturing the body.
|
||||
// Continue and send over the Responses API, capturing the body.
|
||||
items.push(ConversationItem::user("q2"));
|
||||
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
@@ -505,9 +482,9 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 4. The reconstructed reasoning must appear as a typed `reasoning`
|
||||
// input item with its fields preserved verbatim — the byte-stable
|
||||
// typed round-trip, not a flattened string.
|
||||
// The reconstructed reasoning must appear as a typed `reasoning`
|
||||
// input item with its fields preserved verbatim — the byte-stable
|
||||
// typed round-trip, not a flattened string.
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
let input = body.get("input").unwrap().as_array().unwrap();
|
||||
let reasoning = input
|
||||
@@ -550,10 +527,10 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
|
||||
/// block" after cross-model histories (see `prune_replayed_thinking`).
|
||||
#[tokio::test]
|
||||
async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loop() {
|
||||
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl whose
|
||||
// assistant turn issued a tool call (thinking blocks never carried an
|
||||
// id — stream/messages.rs sets id="" — and the signature lives in
|
||||
// `encrypted`). The pending tool_result makes this the active loop.
|
||||
// Seed a legacy Anthropic Messages-origin chat_history.jsonl whose
|
||||
// assistant turn issued a tool call (thinking blocks never carried an
|
||||
// id — stream/messages.rs sets id="" — and the signature lives in
|
||||
// `encrypted`). The pending tool_result makes this the active loop.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("chat_history.jsonl"),
|
||||
@@ -570,7 +547,7 @@ async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loo
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 2. Load + upgrade.
|
||||
// Load + upgrade.
|
||||
let adapter = JsonlStorageAdapter::with_root(dir.path().to_path_buf());
|
||||
let items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
|
||||
assert!(
|
||||
@@ -580,7 +557,7 @@ async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loo
|
||||
"legacy inline reasoning must be reconstructed as a sibling on load, got {items:?}"
|
||||
);
|
||||
|
||||
// 3. Send the tool-loop continuation over the Messages API.
|
||||
// Send the tool-loop continuation over the Messages API.
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
server.set_response("ok");
|
||||
let client = create_test_client(&server.url(), ApiBackend::Messages);
|
||||
@@ -590,8 +567,8 @@ async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loo
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 4. The active loop's reconstructed reasoning must emit an Anthropic
|
||||
// `thinking` content block carrying the thinking text + signature.
|
||||
// The active loop's reconstructed reasoning must emit an Anthropic
|
||||
// `thinking` content block carrying the thinking text + signature.
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
let messages = body.get("messages").unwrap().as_array().unwrap();
|
||||
let thinking_block = messages
|
||||
@@ -618,8 +595,8 @@ async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loo
|
||||
"signature (encrypted) preserved — required to reuse the thought server-side"
|
||||
);
|
||||
|
||||
// 5. A follow-up user turn CLOSES the loop: the same history plus a new
|
||||
// user message must replay NO thinking block at all.
|
||||
// A follow-up user turn CLOSES the loop: the same history plus a new
|
||||
// user message must replay NO thinking block at all.
|
||||
let mut closed = items;
|
||||
closed.push(ConversationItem::user("q2"));
|
||||
let _ = client
|
||||
@@ -638,11 +615,6 @@ async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loo
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Responses API Tests
|
||||
// ============================================================================
|
||||
|
||||
/// Generate a Responses API SSE stream with reasoning (including encrypted content).
|
||||
fn responses_api_with_reasoning_stream(
|
||||
reasoning_summary: &str,
|
||||
encrypted_content: Option<&str>,
|
||||
@@ -652,7 +624,6 @@ fn responses_api_with_reasoning_stream(
|
||||
let mut events = Vec::new();
|
||||
let mut seq = 0;
|
||||
|
||||
// response.created event
|
||||
let created = json!({
|
||||
"type": "response.created",
|
||||
"sequence_number": seq,
|
||||
@@ -668,7 +639,6 @@ fn responses_api_with_reasoning_stream(
|
||||
events.push(SseEvent::data(created.to_string()));
|
||||
seq += 1;
|
||||
|
||||
// Build reasoning output item
|
||||
let mut reasoning_item = json!({
|
||||
"type": "reasoning",
|
||||
"id": "reasoning_item_1",
|
||||
@@ -683,7 +653,6 @@ fn responses_api_with_reasoning_stream(
|
||||
reasoning_item["encrypted_content"] = json!(enc);
|
||||
}
|
||||
|
||||
// response.completed event with reasoning
|
||||
let completed = json!({
|
||||
"type": "response.completed",
|
||||
"sequence_number": seq,
|
||||
@@ -835,11 +804,9 @@ async fn test_responses_api_with_reasoning_and_encrypted_content() {
|
||||
use kigi_shell::sampling::rs::OutputItem;
|
||||
match output {
|
||||
OutputItem::Reasoning(r) => {
|
||||
// Check summary text
|
||||
if !r.summary.is_empty() {
|
||||
found_reasoning = true;
|
||||
}
|
||||
// Check encrypted content
|
||||
if let Some(encrypted_content) = &r.encrypted_content {
|
||||
found_encrypted = true;
|
||||
assert!(encrypted_content.contains("enc_base64"));
|
||||
@@ -871,13 +838,12 @@ async fn test_responses_api_with_reasoning_and_encrypted_content() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_responses_api_reasoning_without_encrypted() {
|
||||
// Test reasoning with only visible summary, no encrypted content
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
server.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_with_reasoning_stream(
|
||||
"I need to analyze the code carefully.",
|
||||
None, // No encrypted content
|
||||
None,
|
||||
"Here is my analysis.",
|
||||
"kigi-test",
|
||||
)),
|
||||
@@ -898,7 +864,6 @@ async fn test_responses_api_reasoning_without_encrypted() {
|
||||
use kigi_shell::sampling::rs::OutputItem;
|
||||
if let OutputItem::Reasoning(r) = output {
|
||||
found_reasoning = true;
|
||||
// Should have summary but no encrypted content
|
||||
assert!(!r.summary.is_empty());
|
||||
assert!(r.encrypted_content.is_none());
|
||||
}
|
||||
@@ -909,10 +874,6 @@ async fn test_responses_api_reasoning_without_encrypted() {
|
||||
assert!(found_reasoning);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Handling Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_401_unauthorized() {
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
@@ -928,7 +889,6 @@ async fn test_chat_completions_401_unauthorized() {
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(SamplingError::Auth(_)) = result {
|
||||
// Expected
|
||||
} else {
|
||||
panic!("Expected Auth error");
|
||||
}
|
||||
@@ -971,7 +931,6 @@ async fn test_responses_api_401_unauthorized() {
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(SamplingError::Auth(_)) = result {
|
||||
// Expected
|
||||
} else {
|
||||
panic!("Expected Auth error");
|
||||
}
|
||||
@@ -979,7 +938,6 @@ async fn test_responses_api_401_unauthorized() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stream_error_during_streaming() {
|
||||
// Simulate a stream error mid-response
|
||||
let events = vec![
|
||||
SseEvent::data(
|
||||
json!({
|
||||
@@ -995,7 +953,6 @@ async fn test_stream_error_during_streaming() {
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
// Stream error
|
||||
SseEvent::data(
|
||||
json!({
|
||||
"error": {
|
||||
@@ -1040,12 +997,11 @@ async fn test_stream_error_during_streaming() {
|
||||
assert!(got_error, "Should have received stream error");
|
||||
}
|
||||
|
||||
/// Mirrors `test_stream_error_during_streaming` but exercises the
|
||||
/// Responses API stream-error detection (the second call site for the
|
||||
/// fast-path contains("error") guard).
|
||||
#[tokio::test]
|
||||
async fn test_stream_error_during_responses_streaming() {
|
||||
// Simulate a stream error mid-response on the Responses API path.
|
||||
// This mirrors test_stream_error_during_streaming but exercises the
|
||||
// Responses API stream-error detection (the second call site for the
|
||||
// fast-path contains("error") guard).
|
||||
let events = vec![
|
||||
SseEvent::with_event(
|
||||
"response.output_text.delta",
|
||||
@@ -1059,7 +1015,6 @@ async fn test_stream_error_during_responses_streaming() {
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
// Stream error
|
||||
SseEvent::data(
|
||||
json!({
|
||||
"error": {
|
||||
@@ -1100,10 +1055,6 @@ async fn test_stream_error_during_responses_streaming() {
|
||||
assert!(got_error, "Should have received stream error");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_includes_headers() {
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
@@ -1173,13 +1124,11 @@ async fn test_request_includes_tools() {
|
||||
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
|
||||
// Verify tools were included
|
||||
let tools = body.get("tools").unwrap().as_array().unwrap();
|
||||
assert_eq!(tools.len(), 2);
|
||||
assert_eq!(tools[0]["function"]["name"], "read_file");
|
||||
assert_eq!(tools[1]["function"]["name"], "bash");
|
||||
|
||||
// Verify tool_choice
|
||||
assert_eq!(body.get("tool_choice").unwrap(), "auto");
|
||||
}
|
||||
|
||||
@@ -1201,13 +1150,11 @@ async fn test_responses_api_request_format() {
|
||||
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
|
||||
// Verify Responses API format
|
||||
assert!(body.get("input").is_some());
|
||||
assert_eq!(body.get("temperature").unwrap(), 0.5);
|
||||
assert_eq!(body.get("max_output_tokens").unwrap(), 500);
|
||||
assert_eq!(body.get("stream").unwrap(), true);
|
||||
|
||||
// Verify input items format
|
||||
let input = body.get("input").unwrap().as_array().unwrap();
|
||||
assert!(input.len() >= 2);
|
||||
}
|
||||
@@ -1294,17 +1241,12 @@ async fn test_doom_loop_check_disabled_sends_no_header_and_drops_check_frames()
|
||||
assert_eq!(logged.header("x-kigi-doom-loop-check"), None);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-turn Conversation Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multi_turn_conversation_with_tool_calls() {
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
server.set_response("I've read the file for you.");
|
||||
let client = create_test_client(&server.url(), ApiBackend::ChatCompletions);
|
||||
|
||||
// Simulate a multi-turn conversation with tool call and result
|
||||
let request = ConversationRequest::from_items(vec![
|
||||
ConversationItem::system("You are a helpful assistant."),
|
||||
ConversationItem::user("Read the README file"),
|
||||
@@ -1314,7 +1256,6 @@ async fn test_multi_turn_conversation_with_tool_calls() {
|
||||
arguments: r#"{"path": "README.md"}"#.into(),
|
||||
}]),
|
||||
ConversationItem::tool_result("call_1", "# My Project\n\nThis is a test project."),
|
||||
// The model should now respond based on the file content
|
||||
]);
|
||||
|
||||
let (mut stream, _metadata) = client.conversation_stream(request).await.unwrap();
|
||||
@@ -1338,7 +1279,6 @@ async fn test_responses_api_multi_turn_with_tool_calls() {
|
||||
server.set_response("Done with the file.");
|
||||
let client = create_test_client(&server.url(), ApiBackend::Responses);
|
||||
|
||||
// Multi-turn with tool call and result
|
||||
let request = ConversationRequest::from_items(vec![
|
||||
ConversationItem::user("Read the config"),
|
||||
ConversationItem::assistant_tool_calls(vec![ToolCall {
|
||||
@@ -1363,10 +1303,6 @@ async fn test_responses_api_multi_turn_with_tool_calls() {
|
||||
assert!(completed);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request Counter Tests (verify retry behavior, etc.)
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_single_request_per_stream() {
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
@@ -1381,13 +1317,8 @@ async fn test_single_request_per_stream() {
|
||||
assert_eq!(server.request_count(), 1);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Backend Routing Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_backend_getter_returns_configured_value() {
|
||||
// Verify that the client correctly reports its configured API backend
|
||||
let client_responses = create_test_client("http://localhost/v1", ApiBackend::Responses);
|
||||
assert_eq!(client_responses.api_backend(), ApiBackend::Responses);
|
||||
|
||||
@@ -1395,13 +1326,12 @@ async fn test_api_backend_getter_returns_configured_value() {
|
||||
assert_eq!(client_chat.api_backend(), ApiBackend::ChatCompletions);
|
||||
}
|
||||
|
||||
/// Verifies that when `ApiBackend::Responses` is configured, the client hits
|
||||
/// `/v1/responses` and NOT `/v1/chat/completions`. The session-level dispatch
|
||||
/// in `kigi-sampler` selects the backend stream based on
|
||||
/// `SamplingClient::api_backend()`.
|
||||
#[tokio::test]
|
||||
async fn test_responses_backend_hits_responses_endpoint_not_chat_completions() {
|
||||
// This test verifies that when ApiBackend::Responses is configured,
|
||||
// the client hits /v1/responses and NOT /v1/chat/completions.
|
||||
// The session-level dispatch in `kigi-sampler` selects the
|
||||
// backend stream based on `SamplingClient::api_backend()`.
|
||||
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
// A request to the wrong endpoint fails loudly instead of streaming.
|
||||
server.enqueue_response(
|
||||
@@ -1411,7 +1341,7 @@ async fn test_responses_backend_hits_responses_endpoint_not_chat_completions() {
|
||||
server.set_response("OK");
|
||||
let client = create_test_client(&server.url(), ApiBackend::Responses);
|
||||
|
||||
// Simulate the routing logic from acp_session.rs
|
||||
// Simulate the routing logic from acp_session.rs.
|
||||
match client.api_backend() {
|
||||
ApiBackend::Responses => {
|
||||
let request = ConversationRequest::from_items(vec![ConversationItem::user("Hello")]);
|
||||
@@ -1439,8 +1369,6 @@ async fn test_responses_backend_hits_responses_endpoint_not_chat_completions() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_backend_hits_chat_endpoint_not_responses() {
|
||||
// Verify the inverse: ChatCompletions backend hits /v1/chat/completions
|
||||
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
// A request to the wrong endpoint fails loudly instead of streaming.
|
||||
server.enqueue_response(
|
||||
@@ -1450,7 +1378,7 @@ async fn test_chat_completions_backend_hits_chat_endpoint_not_responses() {
|
||||
server.set_response("OK");
|
||||
let client = create_test_client(&server.url(), ApiBackend::ChatCompletions);
|
||||
|
||||
// Simulate the routing logic from acp_session.rs
|
||||
// Simulate the routing logic from acp_session.rs.
|
||||
match client.api_backend() {
|
||||
ApiBackend::ChatCompletions => {
|
||||
let request = ConversationRequest::from_items(vec![ConversationItem::user("Hello")]);
|
||||
|
||||
@@ -47,8 +47,9 @@ fn locate_session_dir(home: &Path, id: &str) -> PathBuf {
|
||||
);
|
||||
}
|
||||
|
||||
// requires pre-built binary
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
#[ignore]
|
||||
async fn resume_reconciles_orphaned_running_subagent() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
|
||||
@@ -47,8 +47,9 @@ fn read_summary(home: &std::path::Path, session_id: &str) -> serde_json::Value {
|
||||
|
||||
/// A fresh session on a model with a configured reasoning effort must persist
|
||||
/// that effort in `summary.json` without any model/effort switch.
|
||||
// requires pre-built binary
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
#[ignore]
|
||||
async fn test_fresh_session_persists_reasoning_effort() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
@@ -90,8 +91,9 @@ reasoning_effort = "high"
|
||||
|
||||
/// A fresh session on a model with no configured effort must not invent one:
|
||||
/// `summary.json` omits the field (the model uses its server-side default).
|
||||
// requires pre-built binary
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
#[ignore]
|
||||
async fn test_fresh_session_without_effort_omits_field() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
|
||||
@@ -120,12 +120,11 @@ async fn run_scenario(env: &[(&str, &str)]) -> String {
|
||||
bodies.join("\n---\n")
|
||||
}
|
||||
|
||||
// ── Skills ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Defaults (all vendors on): kigi + cursor-vendor + claude-vendor skills present; the
|
||||
/// denylisted vendor builtin `shell` is dropped.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_defaults_include_vendor_skills_but_drop_denylisted() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[]).await;
|
||||
@@ -151,7 +150,8 @@ async fn vendor_compat_defaults_include_vendor_skills_but_drop_denylisted() {
|
||||
|
||||
/// `KIGI_CURSOR_SKILLS_ENABLED=false` drops the cursor-vendor skill; kigi stays.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_cursor_skills_disabled() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[("KIGI_CURSOR_SKILLS_ENABLED", "false")]).await;
|
||||
@@ -171,7 +171,8 @@ async fn vendor_compat_cursor_skills_disabled() {
|
||||
|
||||
/// `KIGI_CLAUDE_SKILLS_ENABLED=false` drops the claude-vendor skill; kigi stays.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_claude_skills_disabled() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[("KIGI_CLAUDE_SKILLS_ENABLED", "false")]).await;
|
||||
@@ -187,11 +188,10 @@ async fn vendor_compat_claude_skills_disabled() {
|
||||
.await;
|
||||
}
|
||||
|
||||
// ── Rules + AGENTS.md ────────────────────────────────────────────────────────
|
||||
|
||||
/// Defaults: all rules and AGENTS.md surfaces are present.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_rules_and_agents_present_by_default() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[]).await;
|
||||
@@ -215,11 +215,10 @@ async fn vendor_compat_rules_and_agents_present_by_default() {
|
||||
.await;
|
||||
}
|
||||
|
||||
// ── Per-cell toggles (rules + agents) ────────────────────────────────────────
|
||||
|
||||
/// `KIGI_CURSOR_RULES_ENABLED=false` drops cursor-vendor rules; claude-vendor rules stay.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_cursor_rules_disabled() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[("KIGI_CURSOR_RULES_ENABLED", "false")]).await;
|
||||
@@ -237,7 +236,8 @@ async fn vendor_compat_cursor_rules_disabled() {
|
||||
|
||||
/// `KIGI_CLAUDE_RULES_ENABLED=false` drops claude-vendor rules; cursor-vendor rules stay.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_claude_rules_disabled() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[("KIGI_CLAUDE_RULES_ENABLED", "false")]).await;
|
||||
@@ -255,7 +255,8 @@ async fn vendor_compat_claude_rules_disabled() {
|
||||
|
||||
/// `KIGI_CURSOR_AGENTS_ENABLED=false` drops cursor-vendor AGENTS.md; claude-vendor stays.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_cursor_agents_disabled() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[("KIGI_CURSOR_AGENTS_ENABLED", "false")]).await;
|
||||
@@ -273,7 +274,8 @@ async fn vendor_compat_cursor_agents_disabled() {
|
||||
|
||||
/// `KIGI_CLAUDE_AGENTS_ENABLED=false` drops claude-vendor AGENTS.md; cursor-vendor stays.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_claude_agents_disabled() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[("KIGI_CLAUDE_AGENTS_ENABLED", "false")]).await;
|
||||
@@ -289,12 +291,11 @@ async fn vendor_compat_claude_agents_disabled() {
|
||||
.await;
|
||||
}
|
||||
|
||||
// ── Cross-vendor combinations ────────────────────────────────────────────────
|
||||
|
||||
/// All cursor-vendor compat OFF: cursor skills, rules, and AGENTS.md all absent;
|
||||
/// all claude-vendor surfaces unaffected.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_all_cursor_disabled() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[
|
||||
@@ -318,7 +319,8 @@ async fn vendor_compat_all_cursor_disabled() {
|
||||
/// All claude-vendor compat OFF: claude skills, rules, and AGENTS.md all absent;
|
||||
/// all cursor-vendor surfaces unaffected.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_all_claude_disabled() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[
|
||||
@@ -341,7 +343,8 @@ async fn vendor_compat_all_claude_disabled() {
|
||||
|
||||
/// All vendor compat OFF: only kigi-native skill survives.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary
|
||||
// requires pre-built binary
|
||||
#[ignore]
|
||||
async fn vendor_compat_all_vendors_disabled() {
|
||||
with_local_set(|| async {
|
||||
let body = run_scenario(&[
|
||||
|
||||
@@ -17,7 +17,6 @@ use kigi_shell::session::info::Info as SessionInfo;
|
||||
use kigi_shell::session::persistence::default_model_id;
|
||||
use kigi_shell::session::storage::{JsonlStorageAdapter, SessionUpdate, StorageAdapter};
|
||||
|
||||
/// Test that xAI session notifications round-trip through storage correctly.
|
||||
#[tokio::test]
|
||||
async fn test_xai_session_notification_storage_roundtrip() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
@@ -29,13 +28,11 @@ async fn test_xai_session_notification_storage_roundtrip() {
|
||||
cwd: "/test/workspace".to_string(),
|
||||
};
|
||||
|
||||
// Initialize the session
|
||||
adapter
|
||||
.init_session(&info, default_model_id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create a diff_review notification
|
||||
let xai_notification = SessionNotification {
|
||||
session_id: session_id.clone(),
|
||||
update: XaiSessionUpdate::DiffReview {
|
||||
@@ -47,7 +44,6 @@ async fn test_xai_session_notification_storage_roundtrip() {
|
||||
meta: Some(json!({ "totalTokens": 1234 })),
|
||||
};
|
||||
|
||||
// Persist the notification
|
||||
adapter
|
||||
.append_update(
|
||||
&info,
|
||||
@@ -56,7 +52,7 @@ async fn test_xai_session_notification_storage_roundtrip() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Also add an ACP notification to verify mixed storage works
|
||||
// Also add an ACP notification to verify mixed storage works.
|
||||
let acp_notification = acp::SessionNotification::new(
|
||||
session_id.clone(),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
||||
@@ -70,7 +66,6 @@ async fn test_xai_session_notification_storage_roundtrip() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Load the session and verify both notifications are present
|
||||
let loaded = adapter.load_session(&info).await.unwrap();
|
||||
assert_eq!(
|
||||
loaded.updates.len(),
|
||||
@@ -78,7 +73,6 @@ async fn test_xai_session_notification_storage_roundtrip() {
|
||||
"Should have 2 updates (1 xAI + 1 ACP)"
|
||||
);
|
||||
|
||||
// Verify xAI notification
|
||||
match &loaded.updates[0] {
|
||||
SessionUpdate::Xai(notification) => {
|
||||
assert_eq!(notification.session_id, session_id);
|
||||
@@ -93,7 +87,6 @@ async fn test_xai_session_notification_storage_roundtrip() {
|
||||
panic!("Expected DiffReview, got different update type");
|
||||
}
|
||||
}
|
||||
// Verify meta is preserved
|
||||
assert_eq!(
|
||||
notification
|
||||
.meta
|
||||
@@ -105,7 +98,6 @@ async fn test_xai_session_notification_storage_roundtrip() {
|
||||
_ => panic!("Expected xAI update as first item"),
|
||||
}
|
||||
|
||||
// Verify ACP notification
|
||||
match &loaded.updates[1] {
|
||||
SessionUpdate::Acp(notification) => {
|
||||
assert_eq!(notification.session_id, session_id);
|
||||
@@ -187,7 +179,6 @@ async fn test_turn_completed_round_trips_through_storage() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that totalTokens can be extracted from both ACP and xAI notifications.
|
||||
#[tokio::test]
|
||||
async fn test_extract_total_tokens_from_mixed_updates() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
@@ -204,7 +195,6 @@ async fn test_extract_total_tokens_from_mixed_updates() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Add ACP notification with totalTokens
|
||||
let acp_notification = acp::SessionNotification::new(
|
||||
session_id.clone(),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
||||
@@ -217,7 +207,6 @@ async fn test_extract_total_tokens_from_mixed_updates() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Add xAI notification with totalTokens
|
||||
let xai_notification = SessionNotification {
|
||||
session_id: session_id.clone(),
|
||||
update: XaiSessionUpdate::DiffReview { content: vec![] },
|
||||
@@ -228,7 +217,6 @@ async fn test_extract_total_tokens_from_mixed_updates() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Add another ACP notification with higher totalTokens
|
||||
let acp_notification2 = acp::SessionNotification::new(
|
||||
session_id.clone(),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
||||
@@ -268,7 +256,6 @@ async fn test_extract_total_tokens_from_mixed_updates() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Test the serialization format of SessionNotification for wire compatibility.
|
||||
#[test]
|
||||
fn test_xai_session_notification_serialization() {
|
||||
let notification = SessionNotification {
|
||||
@@ -284,7 +271,6 @@ fn test_xai_session_notification_serialization() {
|
||||
|
||||
let json = serde_json::to_value(¬ification).unwrap();
|
||||
|
||||
// Print actual JSON for debugging
|
||||
println!(
|
||||
"Serialized JSON: {}",
|
||||
serde_json::to_string_pretty(&json).unwrap()
|
||||
@@ -301,7 +287,6 @@ fn test_xai_session_notification_serialization() {
|
||||
.expect("Expected sessionUpdate tag in update");
|
||||
assert_eq!(session_update_tag, "diff_review");
|
||||
|
||||
// Verify diff content is inside the update
|
||||
let content = update_obj
|
||||
.get("content")
|
||||
.expect("Expected content in update")
|
||||
|
||||
@@ -34,7 +34,8 @@ const CANONICAL_FIXTURES: &[&str] = &[
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Fixture {
|
||||
name: String,
|
||||
#[allow(dead_code)] // human-readable; surfaced only on assertion failure
|
||||
// human-readable; surfaced only on assertion failure
|
||||
#[allow(dead_code)]
|
||||
description: String,
|
||||
turns: Vec<Turn>,
|
||||
}
|
||||
@@ -42,7 +43,8 @@ struct Fixture {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
enum Turn {
|
||||
#[allow(dead_code)] // user turns are walked but never gate-evaluated
|
||||
// user turns are walked but never gate-evaluated
|
||||
#[allow(dead_code)]
|
||||
User(UserTurn),
|
||||
Assistant(AssistantTurn),
|
||||
}
|
||||
@@ -57,7 +59,8 @@ struct UserTurn {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AssistantTurn {
|
||||
turn_index: usize,
|
||||
#[allow(dead_code)] // present for fixture clarity; the gate does not consult it
|
||||
// present for fixture clarity; the gate does not consult it
|
||||
#[allow(dead_code)]
|
||||
tool_calls_emitted: Vec<serde_json::Value>,
|
||||
todo_state_after_turn: Vec<TodoSnapshot>,
|
||||
backing_task_count: usize,
|
||||
@@ -70,7 +73,8 @@ struct AssistantTurn {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TodoSnapshot {
|
||||
#[allow(dead_code)] // id is preserved for fixture readability
|
||||
// id is preserved for fixture readability
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
status: TodoStatus,
|
||||
content: String,
|
||||
|
||||
Reference in New Issue
Block a user