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.
454 lines
19 KiB
Rust
454 lines
19 KiB
Rust
#![cfg_attr(rustfmt, rustfmt::skip)]
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn goal_updated_ignores_unknown_json_fields_via_serde() {
|
|
// Serde-side half of the forward-compat story: a payload that
|
|
// carries an extra JSON field absent on today's
|
|
// `SessionUpdate::GoalUpdated` (no `deny_unknown_fields` on the
|
|
// variant) must still deserialize and drive a full
|
|
// `GoalDisplayState`. This guards against someone later adding
|
|
// `#[serde(deny_unknown_fields)]` to the variant, which would
|
|
// silently break wire compatibility with older shells.
|
|
//
|
|
// The complementary Rust-level half — that the destructure with
|
|
// trailing `..` keeps absent additive `Option<T>` fields landing
|
|
// as `None` in the mapped `GoalDisplayState` — is exercised by
|
|
// `goal_updated_absent_optional_fields_deserialize_to_none`.
|
|
let mut app = make_app_with_agent("sess-A");
|
|
|
|
let raw_payload = serde_json::json!({
|
|
"sessionId": "sess-A",
|
|
"update": {
|
|
"sessionUpdate": "goal_updated",
|
|
"goal_id": "g-ext",
|
|
"objective": "build forward-compat tolerance",
|
|
"status": "active",
|
|
"phase": "executing",
|
|
"token_budget": 200_000,
|
|
"tokens_used": 12_345,
|
|
"elapsed_ms": 750,
|
|
"total_deliverables": 2,
|
|
"completed_deliverables": 1,
|
|
"current_deliverable_idx": 1,
|
|
"current_deliverable_title": "Wire compat",
|
|
"current_subagent_role": "verifier",
|
|
"total_worker_rounds": 5,
|
|
"total_verify_rounds": 2,
|
|
"token_baseline": 100,
|
|
"finished_subagent_tokens": 99,
|
|
"live_subagent_tokens": 4_321,
|
|
"live_tokens_by_model": [["kigi-4", 6_000], ["kigi-3", 4_000]],
|
|
"live_context_pct": 42,
|
|
"live_turn_count": 7,
|
|
"live_tool_call_count": 11,
|
|
"last_event": "verify_started",
|
|
"last_event_detail": "round 2 of 3",
|
|
"last_event_timestamp": "2026-05-24T00:00:00Z",
|
|
// Field absent on today's `SessionUpdate::GoalUpdated` — simulates
|
|
// a future shell adding a new wire field. With trailing `..` in
|
|
// the destructure and no `deny_unknown_fields` on the variant,
|
|
// this must parse and the pager must still produce a
|
|
// GoalDisplayState mapped from the known subset.
|
|
"future_field_for_pr5": "ignored-by-todays-pager"
|
|
}
|
|
});
|
|
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
|
let request = acp::ExtNotification::new("kigi/session_notification", raw.into());
|
|
let (tx, _rx) = tokio::sync::oneshot::channel();
|
|
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
|
request,
|
|
response_tx: tx,
|
|
});
|
|
|
|
let affected = handle(msg, &mut app);
|
|
assert!(
|
|
affected,
|
|
"GoalUpdated for the active agent must request a redraw"
|
|
);
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
let goal = agent
|
|
.goal_state
|
|
.as_ref()
|
|
.expect("GoalUpdated should populate goal_state even with unknown wire fields");
|
|
assert_eq!(goal.goal_id, "g-ext");
|
|
assert_eq!(goal.objective, "build forward-compat tolerance");
|
|
assert_eq!(goal.status, GoalDisplayStatus::Active);
|
|
assert_eq!(goal.phase, GoalDisplayPhase::Executing);
|
|
assert_eq!(goal.token_budget, Some(200_000));
|
|
assert_eq!(goal.tokens_used, 12_345);
|
|
assert_eq!(goal.elapsed_ms, 750);
|
|
assert_eq!(goal.total_deliverables, 2);
|
|
assert_eq!(goal.completed_deliverables, 1);
|
|
assert_eq!(goal.current_deliverable_id, Some(1));
|
|
assert_eq!(
|
|
goal.current_deliverable_title.as_deref(),
|
|
Some("Wire compat")
|
|
);
|
|
assert_eq!(goal.current_subagent_role.as_deref(), Some("verifier"));
|
|
assert_eq!(goal.total_worker_rounds, 5);
|
|
assert_eq!(goal.total_verify_rounds, 2);
|
|
assert_eq!(goal.token_baseline, 100);
|
|
assert_eq!(goal.finished_subagent_tokens, 99);
|
|
assert_eq!(goal.live_subagent_tokens, Some(4_321));
|
|
assert_eq!(
|
|
goal.live_tokens_by_model,
|
|
vec![("kigi-4".to_owned(), 6_000), ("kigi-3".to_owned(), 4_000)],
|
|
"populated per-model breakdown must round-trip wire->display"
|
|
);
|
|
assert_eq!(goal.live_context_pct, Some(42));
|
|
assert_eq!(goal.live_turn_count, Some(7));
|
|
assert_eq!(goal.live_tool_call_count, Some(11));
|
|
assert_eq!(goal.last_event.as_deref(), Some("verify_started"));
|
|
assert_eq!(goal.last_event_detail.as_deref(), Some("round 2 of 3"));
|
|
assert_eq!(
|
|
goal.last_event_timestamp.as_deref(),
|
|
Some("2026-05-24T00:00:00Z")
|
|
);
|
|
assert_eq!(goal.pause_message, None);
|
|
// Classifier fields default to `None` / `false` when absent.
|
|
assert_eq!(goal.classifier_runs_attempted, None);
|
|
assert_eq!(goal.classifier_max_runs, None);
|
|
assert_eq!(goal.last_classifier_verdict, None);
|
|
assert_eq!(goal.last_classifier_details_path, None);
|
|
assert!(!goal.verifying_completion);
|
|
assert!(!goal.planning);
|
|
assert!(
|
|
goal.deliverables.is_empty(),
|
|
"deliverables is wire-compat-only in the simplified goal model"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn goal_complete_transition_pushes_end_to_end_marker_once() {
|
|
let mut app = make_app_with_agent("sess-A");
|
|
|
|
let send = |app: &mut AppView, status: &str, elapsed_ms: u64| {
|
|
let raw_payload = serde_json::json!({
|
|
"sessionId": "sess-A",
|
|
"update": {
|
|
"sessionUpdate": "goal_updated",
|
|
"goal_id": "g1",
|
|
"objective": "obj",
|
|
"status": status,
|
|
"phase": "executing",
|
|
"tokens_used": 0,
|
|
"elapsed_ms": elapsed_ms,
|
|
"total_deliverables": 0,
|
|
"completed_deliverables": 0,
|
|
"total_worker_rounds": 0,
|
|
"total_verify_rounds": 0,
|
|
"token_baseline": 0,
|
|
"finished_subagent_tokens": 0,
|
|
}
|
|
});
|
|
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
|
let (tx, _rx) = tokio::sync::oneshot::channel();
|
|
handle(
|
|
AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
|
request: acp::ExtNotification::new("kigi/session_notification", raw.into()),
|
|
response_tx: tx,
|
|
}),
|
|
app,
|
|
);
|
|
};
|
|
|
|
let goal_markers = |app: &AppView| -> Vec<std::time::Duration> {
|
|
let sb = &app.agents.get(&AgentId(0)).unwrap().scrollback;
|
|
(0..sb.len())
|
|
.filter_map(|i| match sb.get(i).map(|e| &e.block) {
|
|
Some(RenderBlock::SessionEvent(b)) => match &b.event {
|
|
SessionEvent::GoalCompleted { elapsed } => Some(*elapsed),
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
send(&mut app, "active", 1_000);
|
|
assert!(goal_markers(&app).is_empty(), "no marker while Active");
|
|
|
|
send(&mut app, "complete", 619_000);
|
|
assert_eq!(
|
|
goal_markers(&app),
|
|
vec![std::time::Duration::from_millis(619_000)],
|
|
"transition to Complete pushes one e2e marker with the goal's total time",
|
|
);
|
|
|
|
// A repeat Complete update (e.g. a late notification) must not
|
|
// duplicate the marker.
|
|
send(&mut app, "complete", 620_000);
|
|
assert_eq!(
|
|
goal_markers(&app).len(),
|
|
1,
|
|
"repeat Complete must not push a second marker",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn goal_elapsed_is_monotonic_across_updates() {
|
|
// The displayed elapsed must never tick backward when a notification's
|
|
// authoritative base is below the already-extrapolated value;
|
|
// `elapsed_floor_ms` clamps it.
|
|
let mut app = make_app_with_agent("sess-A");
|
|
assert!(send_goal_update(&mut app, "g1", "active", 10_000));
|
|
let a = app
|
|
.agents
|
|
.get(&AgentId(0))
|
|
.unwrap()
|
|
.goal_state
|
|
.as_ref()
|
|
.unwrap()
|
|
.live_elapsed_ms();
|
|
assert!(a >= 10_000);
|
|
|
|
// Same goal, but a LOWER authoritative base (extrapolation outran the
|
|
// shell's flush point).
|
|
send_goal_update(&mut app, "g1", "active", 8_000);
|
|
let b = app
|
|
.agents
|
|
.get(&AgentId(0))
|
|
.unwrap()
|
|
.goal_state
|
|
.as_ref()
|
|
.unwrap()
|
|
.live_elapsed_ms();
|
|
assert!(b >= a, "elapsed must not tick backward: {b} < {a}");
|
|
assert!(b >= 10_000);
|
|
}
|
|
|
|
#[test]
|
|
fn cleared_goal_is_not_resurrected_by_late_update() {
|
|
// After a goal is cleared, a late in-flight GoalUpdated for the same
|
|
// goal_id (queued before the clear) must be dropped so the "Done"
|
|
// chip / modal stay cleared and don't resurrect.
|
|
let mut app = make_app_with_agent("sess-A");
|
|
send_goal_update(&mut app, "g1", "complete", 5_000);
|
|
assert!(
|
|
app.agents.get(&AgentId(0)).unwrap().goal_state.is_some(),
|
|
"goal present after complete"
|
|
);
|
|
|
|
// Clear (the cleared event itself carries an empty goal_id).
|
|
send_goal_update(&mut app, "", "cleared", 0);
|
|
assert!(
|
|
app.agents.get(&AgentId(0)).unwrap().goal_state.is_none(),
|
|
"chip cleared on cleared status"
|
|
);
|
|
|
|
// A stale late update for the cleared goal must not resurrect it.
|
|
let affected = send_goal_update(&mut app, "g1", "complete", 5_000);
|
|
assert!(
|
|
app.agents.get(&AgentId(0)).unwrap().goal_state.is_none(),
|
|
"cleared goal must not resurrect"
|
|
);
|
|
assert!(!affected, "ignored stale update must not request a redraw");
|
|
}
|
|
|
|
#[test]
|
|
fn new_goal_after_clear_is_not_suppressed() {
|
|
// A genuinely new goal (different id) after a clear must start
|
|
// normally — the cleared-id guard only drops the SAME id.
|
|
let mut app = make_app_with_agent("sess-A");
|
|
send_goal_update(&mut app, "g1", "active", 1_000);
|
|
send_goal_update(&mut app, "", "cleared", 0);
|
|
assert!(send_goal_update(&mut app, "g2", "active", 500));
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent.goal_state.as_ref().expect("new goal present").goal_id,
|
|
"g2"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn goal_switch_resets_elapsed_floor() {
|
|
// A NEW goal (different id) must start its own clock and NOT inherit
|
|
// the prior goal's carried elapsed floor.
|
|
let mut app = make_app_with_agent("sess-A");
|
|
send_goal_update(&mut app, "g1", "active", 10_000);
|
|
// Switch directly to a different goal with a small elapsed base.
|
|
send_goal_update(&mut app, "g2", "active", 500);
|
|
let elapsed = app
|
|
.agents
|
|
.get(&AgentId(0))
|
|
.unwrap()
|
|
.goal_state
|
|
.as_ref()
|
|
.unwrap()
|
|
.live_elapsed_ms();
|
|
assert!(
|
|
elapsed < 5_000,
|
|
"new goal must start from its own base, not the prior 10s floor: {elapsed}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn goal_updated_resolves_details_path_existence_on_receipt() {
|
|
// The handler resolves last_classifier_details_path's existence ONCE
|
|
// on receipt into the cached bool (no per-frame stat).
|
|
let mut app = make_app_with_agent("sess-A");
|
|
|
|
// A real on-disk path → cached exists = true.
|
|
let f = tempfile::NamedTempFile::new().unwrap();
|
|
let real_path = f.path().to_string_lossy().into_owned();
|
|
let mut update = goal_update_value("g1", "active", 0);
|
|
update["last_classifier_details_path"] = serde_json::json!(real_path);
|
|
dispatch_goal_update(&mut app, update);
|
|
let g = app
|
|
.agents
|
|
.get(&AgentId(0))
|
|
.unwrap()
|
|
.goal_state
|
|
.as_ref()
|
|
.unwrap();
|
|
assert!(
|
|
g.last_classifier_details_exists,
|
|
"existing details path must cache exists = true"
|
|
);
|
|
assert_eq!(
|
|
g.last_classifier_details_path.as_deref(),
|
|
Some(real_path.as_str())
|
|
);
|
|
|
|
// A missing path → cached exists = false (modal renders "(unavailable)").
|
|
let mut update = goal_update_value("g1", "active", 0);
|
|
update["last_classifier_details_path"] = serde_json::json!("/no/such/details-xyz.md");
|
|
dispatch_goal_update(&mut app, update);
|
|
let g = app
|
|
.agents
|
|
.get(&AgentId(0))
|
|
.unwrap()
|
|
.goal_state
|
|
.as_ref()
|
|
.unwrap();
|
|
assert!(
|
|
!g.last_classifier_details_exists,
|
|
"missing details path must cache exists = false"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn goal_updated_absent_optional_fields_deserialize_to_none() {
|
|
// Rust-level forward-compat half: every additive
|
|
// `Option<T>` field on `SessionUpdate::GoalUpdated` is allowed to
|
|
// be omitted from the wire payload and must surface as `None` in
|
|
// the destructured arm — i.e. the pager keeps mapping the known
|
|
// subset cleanly when the shell-side struct grows or when an
|
|
// older shell omits newer optional fields. Drop a handful of
|
|
// optional keys from the payload and assert they materialise as
|
|
// `None` on the resulting `GoalDisplayState`.
|
|
let mut app = make_app_with_agent("sess-A");
|
|
|
|
let raw_payload = serde_json::json!({
|
|
"sessionId": "sess-A",
|
|
"update": {
|
|
"sessionUpdate": "goal_updated",
|
|
"goal_id": "g-min",
|
|
"objective": "minimal payload",
|
|
"status": "active",
|
|
"phase": "idle",
|
|
// token_budget omitted — Option<i64> must default to None.
|
|
"tokens_used": 0,
|
|
"elapsed_ms": 0,
|
|
"total_deliverables": 0,
|
|
"completed_deliverables": 0,
|
|
// current_deliverable_idx omitted — Option<u32> -> None.
|
|
// current_deliverable_title omitted — Option<String> -> None.
|
|
// current_subagent_role omitted — Option<String> -> None.
|
|
"total_worker_rounds": 0,
|
|
"total_verify_rounds": 0,
|
|
"token_baseline": 0,
|
|
"finished_subagent_tokens": 0,
|
|
// live_subagent_tokens omitted — Option<u64> -> None.
|
|
// live_context_pct omitted — Option<u8> -> None.
|
|
// live_turn_count omitted — Option<u32> -> None.
|
|
// live_tool_call_count omitted — Option<u32> -> None.
|
|
// last_event omitted — Option<String> -> None.
|
|
// last_event_detail omitted — Option<String> -> None.
|
|
// last_event_timestamp omitted — Option<String> -> None.
|
|
// pause_message omitted — Option<String> -> None.
|
|
}
|
|
});
|
|
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
|
let request = acp::ExtNotification::new("kigi/session_notification", raw.into());
|
|
let (tx, _rx) = tokio::sync::oneshot::channel();
|
|
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
|
request,
|
|
response_tx: tx,
|
|
});
|
|
|
|
let affected = handle(msg, &mut app);
|
|
assert!(
|
|
affected,
|
|
"minimal GoalUpdated for the active agent must request a redraw"
|
|
);
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
let goal = agent
|
|
.goal_state
|
|
.as_ref()
|
|
.expect("GoalUpdated must populate goal_state even with all Option fields omitted");
|
|
|
|
// Required fields landed as sent.
|
|
assert_eq!(goal.goal_id, "g-min");
|
|
assert_eq!(goal.objective, "minimal payload");
|
|
assert_eq!(goal.status, GoalDisplayStatus::Active);
|
|
assert_eq!(goal.phase, GoalDisplayPhase::Idle);
|
|
assert_eq!(goal.tokens_used, 0);
|
|
assert_eq!(goal.elapsed_ms, 0);
|
|
assert_eq!(goal.total_deliverables, 0);
|
|
assert_eq!(goal.completed_deliverables, 0);
|
|
assert_eq!(goal.total_worker_rounds, 0);
|
|
assert_eq!(goal.total_verify_rounds, 0);
|
|
assert_eq!(goal.token_baseline, 0);
|
|
assert_eq!(goal.finished_subagent_tokens, 0);
|
|
|
|
// Every omitted Option<T> wire field must surface as None — this
|
|
// is the property that keeps the destructure stable as the shell
|
|
// grows additive optional fields.
|
|
assert_eq!(goal.token_budget, None, "token_budget");
|
|
assert_eq!(goal.current_deliverable_id, None, "current_deliverable_id");
|
|
assert_eq!(
|
|
goal.current_deliverable_title, None,
|
|
"current_deliverable_title"
|
|
);
|
|
assert_eq!(goal.current_subagent_role, None, "current_subagent_role");
|
|
assert_eq!(goal.live_subagent_tokens, None, "live_subagent_tokens");
|
|
assert!(
|
|
goal.live_tokens_by_model.is_empty(),
|
|
"omitted live_tokens_by_model must default to empty via #[serde(default)]"
|
|
);
|
|
assert_eq!(goal.live_context_pct, None, "live_context_pct");
|
|
assert_eq!(goal.live_turn_count, None, "live_turn_count");
|
|
assert_eq!(goal.live_tool_call_count, None, "live_tool_call_count");
|
|
assert_eq!(goal.last_event, None, "last_event");
|
|
assert_eq!(goal.last_event_detail, None, "last_event_detail");
|
|
assert_eq!(goal.last_event_timestamp, None, "last_event_timestamp");
|
|
assert_eq!(goal.pause_message, None, "pause_message");
|
|
assert_eq!(
|
|
goal.classifier_runs_attempted, None,
|
|
"classifier_runs_attempted"
|
|
);
|
|
assert_eq!(goal.classifier_max_runs, None, "classifier_max_runs");
|
|
assert_eq!(
|
|
goal.last_classifier_verdict, None,
|
|
"last_classifier_verdict"
|
|
);
|
|
assert_eq!(
|
|
goal.last_classifier_details_path, None,
|
|
"last_classifier_details_path"
|
|
);
|
|
assert!(
|
|
!goal.verifying_completion,
|
|
"verifying_completion defaults to false"
|
|
);
|
|
assert!(!goal.planning, "planning defaults to false");
|
|
assert!(
|
|
goal.deliverables.is_empty(),
|
|
"deliverables is wire-compat-only in the simplified goal model"
|
|
);
|
|
}
|
|
|