Files
kebab/crates/kebab-cli/tests/wire_ask_multi_hop.rs
altair823 685007789a style: cargo fmt --all (round 4 ingest log feature follow-up)
Phase C4 executor 의 마지막 `fix(test): clippy + fmt fixes` commit 이
test file 부분만 fmt 적용. workspace 전체 fmt 누락 발견 → cargo fmt --all
적용. 모든 import alphabetical reorder + line wrapping 정합.

추가 untracked artifact 동시 commit:
- docs/superpowers/specs/2026-05-28-v0.20-ingest-log-spec.md (491 line, ACCEPT)
- docs/superpowers/plans/2026-05-28-v0.20-ingest-log-plan.md (616 line, ACCEPT)

workspace test: 1370 passed / 0 failed / 50 ignored, ingest_log_smoke green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:18:40 +00:00

254 lines
9.4 KiB
Rust

//! p9-fb-41 PR-4: CLI `--multi-hop` flag wiring + answer.v1 / error.v1
//! schema additivity.
//!
//! Four Ollama-free pins:
//!
//! 1. `--multi-hop` is exposed on `kebab ask --help` so users can
//! discover the flag at the CLI surface (clap-level smoke).
//! 2. `answer.schema.json` parses as valid JSON and declares a
//! `hops` property with a `HopRecord` `$defs` entry — guards
//! against accidental schema deletion / typo in future edits.
//! 3. `answer.schema.json`'s `refusal_reason` enum lists
//! `multi_hop_decompose_failed` — agents validating against
//! the schema accept the new variant on refusal answers.
//! 4. `error.schema.json`'s `code` enum lists
//! `multi_hop_decompose_failed` — forward-looking enum extension
//! documented in PR-4.
//!
//! End-to-end multi-hop ask against a live Ollama lands in a
//! follow-up `#[ignore]` test (same pattern as `wire_ask_stale.rs`).
use std::path::PathBuf;
use std::process::Command;
fn schema_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("docs")
.join("wire-schema")
.join("v1")
.join(name)
}
fn parse_schema(name: &str) -> serde_json::Value {
let text =
std::fs::read_to_string(schema_path(name)).unwrap_or_else(|e| panic!("read {name}: {e}"));
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name} must parse as valid JSON: {e}"))
}
#[test]
fn cli_ask_help_advertises_multi_hop_flag() {
let bin = env!("CARGO_BIN_EXE_kebab");
let out = Command::new(bin).args(["ask", "--help"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("--multi-hop"),
"`kebab ask --help` must advertise --multi-hop so users can discover it:\n{stdout}"
);
}
#[test]
fn answer_schema_declares_hops_property_with_hop_record_defs() {
let schema = parse_schema("answer.schema.json");
assert!(
schema["properties"]["hops"].is_object(),
"`hops` property must be declared on answer.v1"
);
// `hops` allows array-or-null (single-pass omits the field;
// multi-hop emits a non-empty array).
let hops_any_of = schema["properties"]["hops"]["anyOf"]
.as_array()
.expect("hops must declare anyOf (array | null)");
assert!(
hops_any_of.iter().any(|v| v["type"] == "array"),
"hops anyOf must include array shape"
);
assert!(
hops_any_of.iter().any(|v| v["type"] == "null"),
"hops anyOf must include null (single-pass omits the field)"
);
// HopRecord $defs entry — guards against accidental deletion or
// structural drift in future schema edits.
let hop_record = &schema["$defs"]["HopRecord"];
assert!(
hop_record.is_object(),
"$defs.HopRecord must be declared so `hops.items` can $ref it"
);
let kind_enum = hop_record["properties"]["kind"]["enum"]
.as_array()
.expect("HopRecord.kind must be an enum");
let kinds: Vec<&str> = kind_enum.iter().filter_map(|v| v.as_str()).collect();
for needed in ["decompose", "decide", "synthesize"] {
assert!(
kinds.contains(&needed),
"HopRecord.kind enum must include {needed:?}, got {kinds:?}"
);
}
}
#[test]
fn answer_schema_refusal_reason_enum_includes_multi_hop_decompose_failed() {
let schema = parse_schema("answer.schema.json");
let refusal_any_of = schema["properties"]["refusal_reason"]["anyOf"]
.as_array()
.expect("refusal_reason must declare anyOf");
let enum_arr = refusal_any_of
.iter()
.find_map(|v| v["enum"].as_array())
.expect("one of refusal_reason.anyOf entries must declare an enum");
let values: Vec<&str> = enum_arr.iter().filter_map(|v| v.as_str()).collect();
assert!(
values.contains(&"multi_hop_decompose_failed"),
"refusal_reason enum must include `multi_hop_decompose_failed`, got {values:?}"
);
// All earlier RefusalReason wire values remain on the enum —
// guards against an accidental rewrite dropping old variants.
for needed in [
"score_gate",
"llm_self_judge",
"no_index",
"no_chunks",
"llm_stream_aborted",
] {
assert!(
values.contains(&needed),
"refusal_reason enum must keep prior variant {needed:?}, got {values:?}"
);
}
}
#[test]
fn error_schema_code_enum_includes_multi_hop_decompose_failed() {
let schema = parse_schema("error.schema.json");
let code_enum = schema["properties"]["code"]["enum"]
.as_array()
.expect("error.v1 must declare code.enum");
let values: Vec<&str> = code_enum.iter().filter_map(|v| v.as_str()).collect();
assert!(
values.contains(&"multi_hop_decompose_failed"),
"error.v1 code enum must include forward-looking `multi_hop_decompose_failed`, got {values:?}"
);
// Existing codes remain — guards against accidental deletion.
for needed in [
"config_invalid",
"not_indexed",
"model_unreachable",
"generic",
] {
assert!(
values.contains(&needed),
"error.v1 code enum must keep prior code {needed:?}, got {values:?}"
);
}
}
// ── p9-fb-41 PR-9c-1: NLI verification surface pins ─────────────────────
/// answer.v1 must declare a `verification` property AND a
/// `$defs.VerificationSummary` entry with all three required fields.
/// Guards against accidental schema deletion / typo in future edits.
#[test]
fn answer_schema_declares_verification_field_and_defs() {
let schema = parse_schema("answer.schema.json");
assert!(
schema["properties"]["verification"].is_object(),
"`verification` property must be declared on answer.v1"
);
// `verification` allows object-or-null (multi-hop with threshold>0
// emits an object; everything else omits the field).
let v_any_of = schema["properties"]["verification"]["anyOf"]
.as_array()
.expect("verification must declare anyOf (object | null)");
assert!(
v_any_of.iter().any(|v| v["type"] == "null"),
"verification anyOf must include null (single-pass / disabled gate omits the field)"
);
assert!(
v_any_of
.iter()
.any(|v| v["$ref"].as_str() == Some("#/$defs/VerificationSummary")),
"verification anyOf must $ref VerificationSummary"
);
// VerificationSummary $defs entry + required fields.
let vs = &schema["$defs"]["VerificationSummary"];
assert!(
vs.is_object(),
"$defs.VerificationSummary must be declared so verification.anyOf can $ref it"
);
let required: Vec<&str> = vs["required"]
.as_array()
.expect("VerificationSummary.required must be an array")
.iter()
.filter_map(|v| v.as_str())
.collect();
for needed in ["nli_score", "nli_threshold", "nli_passed"] {
assert!(
required.contains(&needed),
"VerificationSummary.required must include {needed:?}, got {required:?}"
);
}
}
#[test]
fn answer_schema_refusal_reason_enum_includes_nli_verification_failed() {
let schema = parse_schema("answer.schema.json");
let refusal_any_of = schema["properties"]["refusal_reason"]["anyOf"]
.as_array()
.expect("refusal_reason must declare anyOf");
let enum_arr = refusal_any_of
.iter()
.find_map(|v| v["enum"].as_array())
.expect("one of refusal_reason.anyOf entries must declare an enum");
let values: Vec<&str> = enum_arr.iter().filter_map(|v| v.as_str()).collect();
assert!(
values.contains(&"nli_verification_failed"),
"refusal_reason enum must include `nli_verification_failed`, got {values:?}"
);
}
#[test]
fn answer_schema_refusal_reason_enum_includes_nli_model_unavailable() {
let schema = parse_schema("answer.schema.json");
let refusal_any_of = schema["properties"]["refusal_reason"]["anyOf"]
.as_array()
.expect("refusal_reason must declare anyOf");
let enum_arr = refusal_any_of
.iter()
.find_map(|v| v["enum"].as_array())
.expect("one of refusal_reason.anyOf entries must declare an enum");
let values: Vec<&str> = enum_arr.iter().filter_map(|v| v.as_str()).collect();
assert!(
values.contains(&"nli_model_unavailable"),
"refusal_reason enum must include `nli_model_unavailable`, got {values:?}"
);
}
#[test]
fn error_schema_code_enum_includes_nli_verification_failed() {
let schema = parse_schema("error.schema.json");
let code_enum = schema["properties"]["code"]["enum"]
.as_array()
.expect("error.v1 must declare code.enum");
let values: Vec<&str> = code_enum.iter().filter_map(|v| v.as_str()).collect();
assert!(
values.contains(&"nli_verification_failed"),
"error.v1 code enum must include forward-looking `nli_verification_failed`, got {values:?}"
);
}
#[test]
fn error_schema_code_enum_includes_nli_model_unavailable() {
let schema = parse_schema("error.schema.json");
let code_enum = schema["properties"]["code"]["enum"]
.as_array()
.expect("error.v1 must declare code.enum");
let values: Vec<&str> = code_enum.iter().filter_map(|v| v.as_str()).collect();
assert!(
values.contains(&"nli_model_unavailable"),
"error.v1 code enum must include forward-looking `nli_model_unavailable`, got {values:?}"
);
}