#![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` 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", "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); 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 { 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", ); 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" ); 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); 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"); 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()) ); 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` 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. 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", "tokens_used": 0, "elapsed_ms": 0, "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 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"); 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); 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" ); }