The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
598 lines
22 KiB
Rust
598 lines
22 KiB
Rust
//! Mock-HTTP integration suite for the server-side doom-loop check on the
|
|
//! Responses API wire: trigger parsing/dedup onto
|
|
//! `ConversationResponse.doom_loop_signals`, the recovery/resample
|
|
//! contract, and a headless config-to-header lane.
|
|
//!
|
|
//! Scripts use `MockInferenceServer`'s FIFO `enqueue_response`: request N
|
|
//! consumes script N, so "turn 1 doomed, request 2 is the resample" needs no
|
|
//! content-keyed dispatch. Parse tests use non-confident triggers (channel
|
|
//! `response`, `low_logprob`, or over-threshold) so they stay orthogonal to
|
|
//! the recovery, which acts only on confident signals.
|
|
|
|
mod common;
|
|
|
|
use common::{create_test_client, test_sampler_config};
|
|
use kigi_sampler::RetryPolicy;
|
|
use kigi_sampling_types::doom_loop::{DoomLoopSignalKind, SAMPLE_CHECK_EVENT_DATA_CUMULATIVE};
|
|
use kigi_shell::sampling::{
|
|
ApiBackend, Client, ConversationItem, ConversationRequest, RequestId, SamplerActor,
|
|
SamplerHandle,
|
|
};
|
|
use kigi_test_support::sse::{
|
|
responses_api_doom_loop_check_events, responses_api_doom_loop_terminal_only_events,
|
|
responses_api_reasoning_and_text_events, responses_api_reasoning_only_events,
|
|
responses_api_with_doom_loop_frame,
|
|
};
|
|
use kigi_test_support::{MockInferenceServer, MockModelEntry, ScriptedResponse};
|
|
|
|
const MODEL: &str = "test-model";
|
|
|
|
/// A sampling client with the doom-loop check enabled (default tunables:
|
|
/// `max_threshold` 8, `max_retries` 2).
|
|
fn doom_loop_client(base_url: &str) -> Client {
|
|
let mut config = test_sampler_config(base_url, ApiBackend::Responses, &[]);
|
|
config.doom_loop_recovery = Some(Default::default());
|
|
Client::new(config).unwrap()
|
|
}
|
|
|
|
/// A sampler actor (the rung that owns retry/recovery) with the given
|
|
/// doom-loop policy. Events are fire-and-forget, so the receiver is dropped.
|
|
fn spawn_actor(base_url: &str, doom_loop_enabled: bool) -> SamplerHandle {
|
|
let mut config = test_sampler_config(base_url, ApiBackend::Responses, &[]);
|
|
if doom_loop_enabled {
|
|
config.doom_loop_recovery = Some(Default::default());
|
|
}
|
|
// Small transport budget so a broken spec fails fast instead of spinning.
|
|
let retry = RetryPolicy {
|
|
max_retries: 2,
|
|
rate_limit_retry_threshold: 2,
|
|
};
|
|
let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
SamplerActor::spawn(config, retry, event_tx)
|
|
}
|
|
|
|
fn user_request(text: &str) -> ConversationRequest {
|
|
ConversationRequest::from_items(vec![ConversationItem::user(text)])
|
|
}
|
|
|
|
fn responses_request_count(server: &MockInferenceServer) -> usize {
|
|
server
|
|
.requests()
|
|
.iter()
|
|
.filter(|e| e.method == "POST" && e.path.contains("/responses"))
|
|
.count()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trigger parsing (live)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Mid-stream check frames populate `doom_loop_signals`, deduplicated across
|
|
/// the cumulative re-sends, with the label grammar fully parsed.
|
|
#[tokio::test]
|
|
async fn mid_stream_check_frames_populate_and_dedupe_signals() {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_doom_loop_check_events(
|
|
&["tail_repetition:4@response", "tail_repetition:2@response"],
|
|
"around and around we go",
|
|
MODEL,
|
|
)),
|
|
);
|
|
let client = doom_loop_client(&server.url());
|
|
|
|
let response = client
|
|
.conversation_collect(user_request("hello"))
|
|
.await
|
|
.expect("a doomed stream still completes");
|
|
|
|
let signals = &response.doom_loop_signals;
|
|
assert_eq!(signals.len(), 2, "cumulative re-sends dedupe by raw label");
|
|
assert_eq!(signals[0].kind, DoomLoopSignalKind::TailRepetition(4));
|
|
assert_eq!(signals[0].channel, "response");
|
|
assert_eq!(signals[0].raw, "tail_repetition:4@response");
|
|
assert_eq!(signals[1].kind, DoomLoopSignalKind::TailRepetition(2));
|
|
// The doomed turn shape itself is preserved: reasoning-only.
|
|
assert!(response.assistant_text().is_empty());
|
|
}
|
|
|
|
/// The inference API's byte-exact cumulative frame parses into both signals
|
|
/// through the full HTTP/SSE client path.
|
|
#[tokio::test]
|
|
async fn byte_exact_cumulative_frame_parses_through_the_client() {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_with_doom_loop_frame(
|
|
SAMPLE_CHECK_EVENT_DATA_CUMULATIVE,
|
|
"thinking",
|
|
"the answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
let client = doom_loop_client(&server.url());
|
|
|
|
let response = client
|
|
.conversation_collect(user_request("hello"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let raws: Vec<&str> = response
|
|
.doom_loop_signals
|
|
.iter()
|
|
.map(|s| s.raw.as_str())
|
|
.collect();
|
|
assert_eq!(
|
|
raws,
|
|
vec!["tail_repetition:4@response", "tail_repetition:2@response"]
|
|
);
|
|
assert!(response.assistant_text().contains("the answer"));
|
|
}
|
|
|
|
/// The terminal-only copy of the signal (no mid-stream frame) also lands on
|
|
/// the response.
|
|
#[tokio::test]
|
|
async fn terminal_only_field_populates_signals() {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_doom_loop_terminal_only_events(
|
|
&["low_logprob@thinking"],
|
|
"brief thought",
|
|
"an ordinary answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
let client = doom_loop_client(&server.url());
|
|
|
|
let response = client
|
|
.conversation_collect(user_request("hello"))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.doom_loop_signals.len(), 1);
|
|
assert_eq!(
|
|
response.doom_loop_signals[0].kind,
|
|
DoomLoopSignalKind::LowLogprob
|
|
);
|
|
assert_eq!(response.doom_loop_signals[0].channel, "thinking");
|
|
assert!(response.empty_reason().is_none(), "a normal answer turn");
|
|
}
|
|
|
|
/// Malformed check frames are swallowed: the stream completes normally, the
|
|
/// answer is intact, and no signal is recorded.
|
|
#[tokio::test]
|
|
async fn malformed_check_frames_complete_cleanly_without_signals() {
|
|
let malformed = [
|
|
// triggers as a string
|
|
r#"{"type":"response.doom_loop_check","doom_loop_check":{"triggers":"tail_repetition:8@thinking"}}"#,
|
|
// triggers as a number
|
|
r#"{"type":"response.doom_loop_check","doom_loop_check":{"triggers":8}}"#,
|
|
// triggers as an array of objects
|
|
r#"{"type":"response.doom_loop_check","doom_loop_check":{"triggers":[{"kind":"tail_repetition"}]}}"#,
|
|
// missing doom_loop_check key entirely
|
|
r#"{"type":"response.doom_loop_check","sequence_number":9}"#,
|
|
// not JSON at all (only the SSE event name identifies it)
|
|
"definitely not json",
|
|
];
|
|
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
for payload in malformed {
|
|
let events = responses_api_with_doom_loop_frame(payload, "hm", "fine", MODEL);
|
|
server.enqueue_response("/v1/responses", ScriptedResponse::sse(events));
|
|
}
|
|
let client = doom_loop_client(&server.url());
|
|
|
|
for payload in malformed {
|
|
let response = client
|
|
.conversation_collect(user_request("hello"))
|
|
.await
|
|
.unwrap_or_else(|e| panic!("stream must survive malformed frame {payload}: {e}"));
|
|
assert!(
|
|
response.doom_loop_signals.is_empty(),
|
|
"no signal from malformed frame {payload}"
|
|
);
|
|
assert!(response.assistant_text().contains("fine"));
|
|
}
|
|
}
|
|
|
|
/// Unknown extra keys on a well-formed frame do not impede parsing.
|
|
#[tokio::test]
|
|
async fn unknown_extra_keys_still_parse() {
|
|
let payload = r#"{"sequence_number":7,"type":"response.doom_loop_check","doom_loop_check":{"triggers":["tail_repetition:4@response"]},"future_field":true}"#;
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
let events = responses_api_with_doom_loop_frame(payload, "hm", "fine", MODEL);
|
|
server.enqueue_response("/v1/responses", ScriptedResponse::sse(events));
|
|
|
|
let response = doom_loop_client(&server.url())
|
|
.conversation_collect(user_request("hello"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.doom_loop_signals.len(), 1);
|
|
assert_eq!(
|
|
response.doom_loop_signals[0].raw,
|
|
"tail_repetition:4@response"
|
|
);
|
|
}
|
|
|
|
/// No check frame and no terminal field: the signal set stays empty.
|
|
#[tokio::test]
|
|
async fn absent_field_leaves_signals_empty() {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
let events = responses_api_reasoning_and_text_events("thinking", "hello", MODEL);
|
|
server.enqueue_response("/v1/responses", ScriptedResponse::sse(events));
|
|
|
|
let response = doom_loop_client(&server.url())
|
|
.conversation_collect(user_request("hello"))
|
|
.await
|
|
.unwrap();
|
|
assert!(response.doom_loop_signals.is_empty());
|
|
}
|
|
|
|
/// Label kinds this client version does not know are preserved verbatim as
|
|
/// `Unknown` (never dropped, never an error).
|
|
#[tokio::test]
|
|
async fn unknown_label_kinds_preserved_as_unknown() {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_doom_loop_check_events(
|
|
&["novel_detector:9@thinking"],
|
|
"hmmm",
|
|
MODEL,
|
|
)),
|
|
);
|
|
|
|
let response = doom_loop_client(&server.url())
|
|
.conversation_collect(user_request("hello"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.doom_loop_signals.len(), 1);
|
|
assert_eq!(
|
|
response.doom_loop_signals[0].kind,
|
|
DoomLoopSignalKind::Unknown("novel_detector:9".to_string())
|
|
);
|
|
assert_eq!(
|
|
response.doom_loop_signals[0].raw,
|
|
"novel_detector:9@thinking"
|
|
);
|
|
}
|
|
|
|
/// With the check disabled, the terminal field is never even parsed — the
|
|
/// policy gates all signal work, not just the header.
|
|
#[tokio::test]
|
|
async fn disabled_policy_leaves_terminal_field_unparsed() {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_doom_loop_terminal_only_events(
|
|
&["tail_repetition:8@thinking"],
|
|
"thinking",
|
|
"an answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
let client = create_test_client(&server.url(), ApiBackend::Responses);
|
|
|
|
let response = client
|
|
.conversation_collect(user_request("hello"))
|
|
.await
|
|
.unwrap();
|
|
assert!(response.doom_loop_signals.is_empty());
|
|
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,
|
|
/// the clean second script is the accepted response, and the resample
|
|
/// request body is identical to the first — the poisoned turn's output never
|
|
/// enters the conversation.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn confident_signal_resamples_once_and_discards_poisoned_turn() {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_doom_loop_terminal_only_events(
|
|
&["tail_repetition:8@thinking"],
|
|
"loop loop loop",
|
|
"poisoned answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_reasoning_and_text_events(
|
|
"fresh thought",
|
|
"clean answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
let handle = spawn_actor(&server.url(), true);
|
|
|
|
let (response, _metrics) = handle
|
|
.submit_and_collect(RequestId::from("doom-confident"), user_request("hello"))
|
|
.await
|
|
.expect("recovery accepts the clean resample");
|
|
|
|
assert_eq!(responses_request_count(&server), 2);
|
|
assert_eq!(response.assistant_text(), "clean answer");
|
|
assert!(
|
|
response.doom_loop_signals.is_empty(),
|
|
"the accepted response is the clean resample, not the poisoned turn"
|
|
);
|
|
let bodies = server.request_bodies();
|
|
assert_eq!(
|
|
bodies[0]["input"], bodies[1]["input"],
|
|
"the resample re-sends the same prefix; poisoned output never enters it"
|
|
);
|
|
}
|
|
|
|
/// Budget exhaustion: with `max_retries` 2, three consecutively doomed turns
|
|
/// consume the budget and the LAST doomed response is accepted as-is — the
|
|
/// turn still succeeds.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn budget_exhaustion_accepts_last_doomed_response() {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
for _ in 0..3 {
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_doom_loop_terminal_only_events(
|
|
&["tail_repetition:8@thinking"],
|
|
"loop loop loop",
|
|
"still looping answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
}
|
|
let handle = spawn_actor(&server.url(), true);
|
|
|
|
let (response, _metrics) = handle
|
|
.submit_and_collect(RequestId::from("doom-budget"), user_request("hello"))
|
|
.await
|
|
.expect("an exhausted budget accepts the response instead of erroring");
|
|
|
|
assert_eq!(
|
|
responses_request_count(&server),
|
|
3,
|
|
"initial attempt + max_retries (2) resamples"
|
|
);
|
|
assert_eq!(response.assistant_text(), "still looping answer");
|
|
assert!(
|
|
!response.doom_loop_signals.is_empty(),
|
|
"the accepted doomed response keeps its signals (warn-only fallback)"
|
|
);
|
|
}
|
|
|
|
/// Non-confident signals never resample: threshold above `max_threshold`,
|
|
/// a non-thinking channel, and `low_logprob` are warn-only. The
|
|
/// misclassification fence for the recovery's confidence rule.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn not_confident_signals_do_not_resample() {
|
|
for trigger in [
|
|
"tail_repetition:64@thinking",
|
|
"tail_repetition:2@response",
|
|
"low_logprob@thinking",
|
|
] {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_doom_loop_terminal_only_events(
|
|
&[trigger],
|
|
"some thought",
|
|
"kept answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
let handle = spawn_actor(&server.url(), true);
|
|
|
|
let (response, _metrics) = handle
|
|
.submit_and_collect(RequestId::from("doom-lax"), user_request("hello"))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
responses_request_count(&server),
|
|
1,
|
|
"{trigger} is not confident and must not resample"
|
|
);
|
|
assert_eq!(response.assistant_text(), "kept answer");
|
|
assert_eq!(response.doom_loop_signals[0].raw, trigger);
|
|
}
|
|
}
|
|
|
|
/// A disabled policy ignores even a confident signal end-to-end through the
|
|
/// actor: one request, field unparsed.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn disabled_policy_ignores_confident_signal() {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_doom_loop_terminal_only_events(
|
|
&["tail_repetition:8@thinking"],
|
|
"loop loop loop",
|
|
"accepted anyway",
|
|
MODEL,
|
|
)),
|
|
);
|
|
let handle = spawn_actor(&server.url(), false);
|
|
|
|
let (response, _metrics) = handle
|
|
.submit_and_collect(RequestId::from("doom-disabled"), user_request("hello"))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(responses_request_count(&server), 1);
|
|
assert_eq!(response.assistant_text(), "accepted anyway");
|
|
assert!(response.doom_loop_signals.is_empty());
|
|
}
|
|
|
|
/// A confident signal arriving mid-stream aborts the attempt and resamples.
|
|
/// The early abort itself is not externally assertable; the contract is two
|
|
/// requests and the clean final response. The poisoned script carries a
|
|
/// visible answer so the existing empty-response retry cannot mask the
|
|
/// doom-loop path.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn mid_stream_signal_aborts_and_resamples() {
|
|
let confident_frame = r#"{"type":"response.doom_loop_check","doom_loop_check":{"triggers":["tail_repetition:8@thinking"]}}"#;
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_with_doom_loop_frame(
|
|
confident_frame,
|
|
"loop loop loop",
|
|
"poisoned answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_reasoning_and_text_events(
|
|
"fresh thought",
|
|
"clean answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
let handle = spawn_actor(&server.url(), true);
|
|
|
|
let (response, _metrics) = handle
|
|
.submit_and_collect(RequestId::from("doom-midstream"), user_request("hello"))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(responses_request_count(&server), 2);
|
|
assert_eq!(response.assistant_text(), "clean answer");
|
|
}
|
|
|
|
/// The doom-loop budget and the existing empty-response retry class coexist,
|
|
/// one debit each: turn 1 is doomed but NON-empty (confident trigger plus a
|
|
/// visible answer), so only the doom class can advance past it; turn 2 is
|
|
/// reasoning-only without a trigger, so only the empty class fires; turn 3
|
|
/// is the clean accept.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn doomed_then_reasoning_only_empty_coexist() {
|
|
let server = MockInferenceServer::start().await.unwrap();
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_doom_loop_terminal_only_events(
|
|
&["tail_repetition:8@thinking"],
|
|
"loop loop loop",
|
|
"poisoned answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_reasoning_only_events(
|
|
"empty but not doomed",
|
|
MODEL,
|
|
)),
|
|
);
|
|
server.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_reasoning_and_text_events(
|
|
"fresh thought",
|
|
"clean answer",
|
|
MODEL,
|
|
)),
|
|
);
|
|
let handle = spawn_actor(&server.url(), true);
|
|
|
|
let (response, _metrics) = handle
|
|
.submit_and_collect(RequestId::from("doom-coexist"), user_request("hello"))
|
|
.await
|
|
.expect("both retry classes stay within their budgets");
|
|
|
|
assert_eq!(responses_request_count(&server), 3);
|
|
assert_eq!(response.assistant_text(), "clean answer");
|
|
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
|
|
/// `x-kigi-turn-idx`) carries the opt-in header. Aux side-queries the binary
|
|
/// also fires at `/v1/responses` (e.g. session-title generation) must NOT
|
|
/// carry it — they collect without the actor's retry loop, so an armed
|
|
/// abort there could only fail them, never resample. The recovery behavior
|
|
/// itself is covered by the mock-HTTP suite above.
|
|
///
|
|
/// `#[ignore]` (needs a built binary). Run locally (auto-builds the pager):
|
|
/// ```bash
|
|
/// cargo test -p kigi-shell --test test_doom_loop_recovery -- --ignored
|
|
/// ```
|
|
#[tokio::test]
|
|
#[ignore] // requires pre-built binary; run with --ignored
|
|
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)
|
|
.await
|
|
.expect("start mock server");
|
|
let workdir = kigi_test_support::git_workdir();
|
|
let home = tempfile::TempDir::new().unwrap();
|
|
|
|
let kigi_home = home.path().join(".kigi");
|
|
std::fs::create_dir_all(&kigi_home).expect("create .kigi home");
|
|
std::fs::write(
|
|
kigi_home.join("config.toml"),
|
|
"[doom_loop_recovery]\nenabled = true\n",
|
|
)
|
|
.expect("write config.toml");
|
|
|
|
let mut cmd = tokio::process::Command::new(kigi_test_support::kigi_binary());
|
|
cmd.args(["-p", "say hi", "--yolo", "--output-format", "json"])
|
|
.arg("--cwd")
|
|
.arg(workdir.path())
|
|
.current_dir(workdir.path())
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.kill_on_drop(true);
|
|
kigi_test_support::env::test_env_cmd_tokio(&mut cmd, &server.url(), home.path());
|
|
cmd.env("KIGI_SHARE_DIR", kigi_home);
|
|
// Don't attach to a developer's ambient leader; spawn fresh against the mock.
|
|
cmd.env_remove("KIGI_LEADER_SOCKET");
|
|
|
|
let result = kigi_test_support::run_headless_with_cmd(cmd).await;
|
|
kigi_test_support::assert_headless_success(&result, "doom-loop header e2e", Some(&server));
|
|
|
|
let requests = server.requests();
|
|
let responses_posts: Vec<_> = requests
|
|
.iter()
|
|
.filter(|e| e.method == "POST" && e.path.contains("/responses"))
|
|
.collect();
|
|
// The session turn carries `x-kigi-turn-idx`; aux side-queries (session
|
|
// title, etc.) do not.
|
|
let (turns, aux): (Vec<_>, Vec<_>) = responses_posts
|
|
.into_iter()
|
|
.partition(|e| e.header("x-kigi-turn-idx").is_some());
|
|
assert!(
|
|
!turns.is_empty(),
|
|
"no session turn POST /v1/responses logged; requests:\n{}",
|
|
server.request_log_summary()
|
|
);
|
|
for turn in turns {
|
|
assert_eq!(
|
|
turn.header("x-kigi-doom-loop-check"),
|
|
Some("true"),
|
|
"[doom_loop_recovery] enabled must reach the turn request header; requests:\n{}",
|
|
server.request_log_summary()
|
|
);
|
|
}
|
|
for side_query in aux {
|
|
assert_eq!(
|
|
side_query.header("x-kigi-doom-loop-check"),
|
|
None::<&str>,
|
|
"the session policy must not leak into aux side-query clients; requests:\n{}",
|
|
server.request_log_summary()
|
|
);
|
|
}
|
|
}
|