diff --git a/AGENTS.md b/AGENTS.md index 9d68db0..545af11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,14 @@ edges stay deterministic Rust. The harness appends a terminal serially on the full goal engine. Concurrency=1 is byte-identical to the serial G0 path. Ceiling: a worker round exceeding the foreground subagent await budget (600s) is cancelled and retried via resume. +- G2: `BudgetLimited` is resumable — a budget trip demotes in-flight + nodes to `Ready` (resource stop, not a verdict) and + `/graph resume --budget ` re-arms with fresh headroom. The + pager shows a graph status chip driven by the `GraphUpdated` wire + variant (`extensions/notification.rs`), emitted from the single + `persist_graph_state` chokepoint (checkpoint ⇔ badge tick); old + pagers degrade via `#[serde(other)] Unknown`. PTY scenarios: + `graph_slash_presession{,_disabled}.yaml`. ## Milestones (PRD §8.3) diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs index ed29120..8508e78 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs @@ -310,8 +310,13 @@ impl MvpAgent { pub(crate) fn command_availability( &self, ) -> crate::session::slash_commands::CommandAvailability { + let goal = self.cfg.borrow().resolve_goal().value; crate::session::slash_commands::CommandAvailability { - goal: self.cfg.borrow().resolve_goal().value, + goal, + // Same convention as /goal: the flag is known at initialize + // time, so advertise pre-session; the in-session path + // re-checks the live toolset. + graph: goal && self.cfg.borrow().resolve_graph().value, ..crate::session::slash_commands::CommandAvailability::default() } } diff --git a/crates/codegen/kigi-shell/src/extensions/notification.rs b/crates/codegen/kigi-shell/src/extensions/notification.rs index 2351547..0bbf8b3 100644 --- a/crates/codegen/kigi-shell/src/extensions/notification.rs +++ b/crates/codegen/kigi-shell/src/extensions/notification.rs @@ -897,6 +897,36 @@ pub enum SessionUpdate { #[serde(default, skip_serializing_if = "Option::is_none")] usage: Option, }, + /// Graph mode (`/graph`) progress for the pager's status chip. + /// Wire tag `graph_updated`; `status: "cleared"` tells the pager to + /// drop its graph state (same sentinel convention as `GoalUpdated`). + /// Old pagers degrade to [`Self::Unknown`] silently. + GraphUpdated { + graph_id: String, + objective: String, + /// Goal-status vocabulary (`active`, paused family, + /// `budget_limited`, `complete`) plus `cleared`. + status: String, + /// `idle` | `planning` | `executing`. + phase: String, + plan_version: u32, + total_nodes: u32, + achieved_nodes: u32, + failed_nodes: u32, + running_nodes: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + current_node: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + current_node_title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + token_budget: Option, + #[serde(default)] + tokens_spent: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_event: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pause_message: Option, + }, /// Catch-all for unrecognized session update types. /// Allows forward/backward compatibility when variants are added or removed. /// All fields from the unrecognized variant are discarded during deserialization. @@ -2293,3 +2323,88 @@ mod tests { assert!(serde_json::from_str::(missing_stop_reason).is_err()); } } + +#[cfg(test)] +mod graph_updated_wire_tests { + use super::*; + + fn full_graph_updated() -> SessionUpdate { + SessionUpdate::GraphUpdated { + graph_id: "g-1".into(), + objective: "ship it".into(), + status: "active".into(), + phase: "executing".into(), + plan_version: 2, + total_nodes: 5, + achieved_nodes: 2, + failed_nodes: 1, + running_nodes: 1, + current_node: Some("gn-abc".into()), + current_node_title: Some("Node C".into()), + token_budget: Some(10_000), + tokens_spent: 4_200, + last_event: Some("node_achieved".into()), + pause_message: None, + } + } + + #[test] + fn graph_updated_round_trips_with_snake_case_tag() { + let update = full_graph_updated(); + let json = serde_json::to_value(&update).unwrap(); + assert_eq!(json["sessionUpdate"], "graph_updated"); + assert_eq!(json["achieved_nodes"], 2); + assert_eq!(json["current_node_title"], "Node C"); + // Omitted optionals must not serialize at all. + assert!(json.get("pause_message").is_none()); + let back: SessionUpdate = serde_json::from_value(json).unwrap(); + assert_eq!(back, update); + } + + #[test] + fn graph_updated_minimal_payload_fills_defaults() { + // Only the required fields on the wire: every optional absent, + // `tokens_spent` relies on #[serde(default)]. + let json = serde_json::json!({ + "sessionUpdate": "graph_updated", + "graph_id": "g-2", + "objective": "o", + "status": "cleared", + "phase": "idle", + "plan_version": 0, + "total_nodes": 0, + "achieved_nodes": 0, + "failed_nodes": 0, + "running_nodes": 0, + }); + let update: SessionUpdate = serde_json::from_value(json).unwrap(); + match update { + SessionUpdate::GraphUpdated { + status, + tokens_spent, + current_node, + token_budget, + pause_message, + .. + } => { + assert_eq!(status, "cleared"); + assert_eq!(tokens_spent, 0, "#[serde(default)] must backfill"); + assert!(current_node.is_none()); + assert!(token_budget.is_none()); + assert!(pause_message.is_none()); + } + other => panic!("expected GraphUpdated, got {other:?}"), + } + } + + /// An OLD pager (this enum before the variant existed) must degrade + /// a graph_updated payload to `Unknown` — pinned by feeding an + /// unknown-tag payload through today's enum, which uses the same + /// #[serde(other)] mechanism. + #[test] + fn unknown_tags_still_degrade_gracefully() { + let json = serde_json::json!({ "sessionUpdate": "graph_updated_v99", "x": 1 }); + let update: SessionUpdate = serde_json::from_value(json).unwrap(); + assert_eq!(update, SessionUpdate::Unknown); + } +} diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/goal.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/goal.rs index b0a50f0..5c328ec 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/goal.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/goal.rs @@ -1908,11 +1908,21 @@ impl SessionActor { // budget, so a node-level budget trip is the graph-level trip. if self.graph_harness_enabled() && self.graph_tracker.lock().is_active() { tracing::warn!("graph: node goal budget trip cascades to graph BudgetLimited"); - self.graph_tracker.lock().budget_limit(); + { + let mut tracker = self.graph_tracker.lock(); + // Budget integrity: charge the tripped node's partial burn + // BEFORE budget_limit clears current_node — otherwise the + // top-up arithmetic runs on an under-counted ledger. + if let Some(node_id) = tracker.current_node_id().map(str::to_owned) { + tracker.charge_node_tokens(&node_id, tokens_used); + } + tracker.budget_limit(); + } self.persist_graph_state(); self.send_slash_command_output(&format!( "Graph token budget reached ({tokens_used} tokens this node) — graph \ - stopped. Use /graph clear, then /graph to start a new one." + stopped. Top up with /graph resume --budget , or /graph clear \ + to abandon." )) .await; return true; diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/graph.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/graph.rs index 8e6e131..6bb8bcf 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/graph.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/graph.rs @@ -55,6 +55,98 @@ pub(super) fn node_goal_objective( ) } +/// Snake-case wire form of a graph/goal status (matches the +/// `GoalUpdated` vocabulary the pager already parses). +fn graph_status_str(status: GoalStatus) -> &'static str { + match status { + GoalStatus::Active => "active", + GoalStatus::UserPaused => "user_paused", + GoalStatus::BackOffPaused => "back_off_paused", + GoalStatus::NoProgressPaused => "no_progress_paused", + GoalStatus::InfraPaused => "infra_paused", + GoalStatus::Blocked => "blocked", + GoalStatus::BudgetLimited => "budget_limited", + GoalStatus::Complete => "complete", + } +} + +fn graph_event_as_str(event: &super::super::graph_tracker::GraphEvent) -> &'static str { + use super::super::graph_tracker::GraphEvent; + match event { + GraphEvent::GraphCreated => "graph_created", + GraphEvent::PlanningStarted => "planning_started", + GraphEvent::PlanningCompleted => "planning_completed", + GraphEvent::PlanningFailed => "planning_failed", + GraphEvent::NodeStarted => "node_started", + GraphEvent::NodeAchieved => "node_achieved", + GraphEvent::NodeFailed => "node_failed", + GraphEvent::GraphPaused => "graph_paused", + GraphEvent::GraphResumed => "graph_resumed", + GraphEvent::GraphCompleted => "graph_completed", + GraphEvent::GraphCleared => "graph_cleared", + GraphEvent::BudgetExceeded => "budget_exceeded", + GraphEvent::Unknown => "unknown", + } +} + +/// Build the pager-facing `GraphUpdated` badge payload from a snapshot. +pub(crate) fn build_graph_updated( + state: &super::super::graph_tracker::GraphOrchestration, +) -> crate::extensions::notification::SessionUpdate { + use super::super::goal_tracker::GoalPhase; + let count = |s: NodeStatus| state.nodes.iter().filter(|n| n.status == s).count() as u32; + let current = state + .current_node + .as_deref() + .and_then(|id| state.nodes.iter().find(|n| n.id == id)); + crate::extensions::notification::SessionUpdate::GraphUpdated { + graph_id: state.graph_id.clone(), + objective: state.objective.clone(), + status: graph_status_str(state.status).to_owned(), + phase: match state.phase { + GoalPhase::Idle => "idle", + GoalPhase::Planning => "planning", + GoalPhase::Executing => "executing", + } + .to_owned(), + plan_version: state.plan_version, + total_nodes: state.nodes.len() as u32, + achieved_nodes: count(NodeStatus::Achieved), + failed_nodes: count(NodeStatus::Failed) + count(NodeStatus::Blocked), + running_nodes: count(NodeStatus::Running) + count(NodeStatus::Verifying), + current_node: current.map(|n| n.id.clone()), + current_node_title: current.map(|n| n.title.clone()), + token_budget: state.token_budget, + tokens_spent: state.tokens_spent_nodes, + last_event: state + .history + .last() + .map(|e| graph_event_as_str(&e.event).to_owned()), + pause_message: state.pause_message.clone(), + } +} + +/// `status: "cleared"` sentinel — the pager drops its graph state. +pub(crate) fn build_graph_cleared() -> crate::extensions::notification::SessionUpdate { + crate::extensions::notification::SessionUpdate::GraphUpdated { + graph_id: String::new(), + objective: String::new(), + status: "cleared".to_owned(), + phase: "idle".to_owned(), + plan_version: 0, + total_nodes: 0, + achieved_nodes: 0, + failed_nodes: 0, + running_nodes: 0, + current_node: None, + current_node_title: None, + token_budget: None, + tokens_spent: 0, + last_event: None, + pause_message: None, + } +} + impl SessionActor { /// Graph feature flag AND the goal harness (nodes execute as goals). pub(super) fn graph_harness_enabled(&self) -> bool { @@ -62,13 +154,21 @@ impl SessionActor { } /// Send the current graph snapshot (or a tombstone after `clear`) to - /// the persistence actor. Every graph transition is a checkpoint. + /// the persistence actor, AND notify the pager: every graph + /// transition is both a checkpoint and a `GraphUpdated` badge tick + /// (single chokepoint — no transition can persist without also + /// updating the UI, and vice versa). pub(crate) fn persist_graph_state(&self) { let snapshot = self.graph_tracker.lock().snapshot().cloned(); + let update = match &snapshot { + Some(state) => build_graph_updated(state), + None => build_graph_cleared(), + }; let _ = self .notifications .persistence_tx .send(PersistenceMsg::GraphModeState(snapshot)); + self.goal_notify_sender().send_update(update); } /// True when the graph occupies the goal engine (any non-terminal @@ -97,9 +197,12 @@ impl SessionActor { ); } let graph_status = self.graph_tracker.lock().status(); - if matches!(graph_status, Some(s) if s == GoalStatus::Active || s.is_paused()) { + // Refuse over ANYTHING non-Complete — including BudgetLimited, + // which is resumable and must never be silently replaced. + if matches!(graph_status, Some(s) if s != GoalStatus::Complete) { return GraphSetupOutcome::Message( - "A graph is already set. Use /graph status, /graph resume, or /graph clear." + "A graph is already set. Use /graph status, /graph resume \ + [--budget ], or /graph clear." .to_owned(), ); } @@ -282,7 +385,8 @@ impl SessionActor { self.graph_tracker.lock().budget_limit(); self.persist_graph_state(); self.send_slash_command_output( - "Graph token budget exhausted. Use /graph clear, then /graph .", + "Graph token budget exhausted. Top up with /graph resume --budget , \ + or /graph clear to abandon.", ) .await; return None; @@ -347,9 +451,21 @@ impl SessionActor { // Node goals are armed with the remaining graph budget, // so a node-level trip IS the graph-level trip. The // enforce-side cascade normally handles this; mirroring - // here is idempotent (budget_limit is Active-only). + // here is idempotent (budget_limit is Active-only), and + // the two charge sites are mutually exclusive with it. tracing::warn!("graph: node goal budget-limited; graph budget-limited"); - self.graph_tracker.lock().budget_limit(); + let current_tokens = self.chat_state_handle.get_total_tokens().await as i64; + let node_tokens = self.goal_tokens_used(current_tokens); + { + let mut tracker = self.graph_tracker.lock(); + // Budget integrity: the tripped node's partial burn + // was still spent — charge it before the demotion + // clears current_node. + if let Some(node_id) = tracker.current_node_id().map(str::to_owned) { + tracker.charge_node_tokens(&node_id, node_tokens); + } + tracker.budget_limit(); + } self.persist_graph_state(); None } @@ -454,7 +570,8 @@ impl SessionActor { self.graph_tracker.lock().budget_limit(); self.persist_graph_state(); self.send_slash_command_output( - "Graph token budget exhausted. Use /graph clear, then /graph .", + "Graph token budget exhausted. Top up with /graph resume --budget , \ + or /graph clear to abandon.", ) .await; return None; @@ -614,7 +731,7 @@ impl SessionActor { /// included); otherwise (post-restart empty engine) launch the next /// `Ready` node fresh — the verifier gates completion, so re-running /// a node is always safe. - pub(super) async fn resume_graph(&self) -> GraphSetupOutcome { + pub(super) async fn resume_graph(&self, extra_budget: Option) -> GraphSetupOutcome { use super::goal_support::GoalResumeOutcome; let status = self.graph_tracker.lock().status(); match status { @@ -627,10 +744,43 @@ impl SessionActor { Some(GoalStatus::Complete) => GraphSetupOutcome::Message( "Graph is already complete. Use /graph to start a new one.".to_owned(), ), - Some(GoalStatus::BudgetLimited) => GraphSetupOutcome::Message( - "Graph is budget-limited. Use /graph clear, then /graph .".to_owned(), - ), + Some(GoalStatus::BudgetLimited) => { + let Some(extra) = extra_budget else { + return GraphSetupOutcome::Message( + "Graph is budget-limited. Top up with /graph resume --budget \ + , or /graph clear to abandon." + .to_owned(), + ); + }; + if !self.graph_tracker.lock().resume_budget_limited(extra) { + return GraphSetupOutcome::Message( + "Budget top-up must be a positive token count.".to_owned(), + ); + } + self.persist_graph_state(); + tracing::info!(extra, "graph: resumed with budget top-up"); + match self.drive_graph().await { + Some(reminder) => GraphSetupOutcome::Inference { + reminder, + user_msg: format!("Graph resumed with {extra} fresh budget tokens."), + }, + None => GraphSetupOutcome::Message( + "Graph resumed with fresh budget but did not enter a serial node. \ + See /graph status." + .to_owned(), + ), + } + } Some(s) if s.is_paused() => { + if extra_budget.is_some() { + // Never silently discard an explicit flag. + return GraphSetupOutcome::Message( + "--budget only applies to a budget-limited graph; this graph is \ + paused. Use /graph resume (no flags) to continue, or /graph \ + clear to abandon." + .to_owned(), + ); + } { let mut tracker = self.graph_tracker.lock(); tracker.resume(); diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs index 7f46966..07f040e 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs @@ -873,7 +873,7 @@ impl SessionActor { BuiltinAction::GraphSet { .. } => { unreachable!("GraphSet is intercepted in handle_prompt") } - BuiltinAction::GraphResume => { + BuiltinAction::GraphResume { .. } => { unreachable!("GraphResume is intercepted in handle_prompt") } } diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs index d1d0cf3..798bd85 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs @@ -1244,6 +1244,13 @@ pub(crate) async fn spawn_session_actor( ), ) .await; + // A restored graph was demoted (Active→UserPaused, Running→Ready) IN + // MEMORY after the updates-log replay, whose last GraphUpdated still + // shows the pre-shutdown Active state. Re-emit truth once so a + // reattached pager never renders a stale self-driving chip. + if session.graph_tracker.lock().snapshot().is_some() { + session.persist_graph_state(); + } if let Some(ref display_cwd) = prompt_display_cwd { session .agent diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs index 2720000..339f3a1 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs @@ -350,16 +350,18 @@ impl SessionActor { return ok_end_turn(0, None); } }, - BuiltinAction::GraphResume => match self.resume_graph().await { - super::graph::GraphSetupOutcome::Inference { reminder, user_msg } => { - self.send_slash_command_output(&user_msg).await; - vec![text_block(reminder)] + BuiltinAction::GraphResume { extra_budget } => { + match self.resume_graph(extra_budget).await { + super::graph::GraphSetupOutcome::Inference { reminder, user_msg } => { + self.send_slash_command_output(&user_msg).await; + vec![text_block(reminder)] + } + super::graph::GraphSetupOutcome::Message(msg) => { + self.send_slash_command_output(&msg).await; + return ok_end_turn(0, None); + } } - super::graph::GraphSetupOutcome::Message(msg) => { - self.send_slash_command_output(&msg).await; - return ok_end_turn(0, None); - } - }, + } _ => return self.execute_builtin_slash_command(action).await, } } diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs index 50061f5..b59fc46 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs @@ -421,7 +421,7 @@ async fn graph_restore_demotes_running_node_and_resume_relaunches_it() { ); // /graph resume relaunches node b as a fresh goal. - match restored.resume_graph().await { + match restored.resume_graph(None).await { graph::GraphSetupOutcome::Inference { reminder, .. } => { assert!( reminder.contains("Graph node 2/4"), @@ -474,9 +474,9 @@ async fn node_budget_is_graph_remaining_and_trip_cascades() { Some(GoalStatus::BudgetLimited), "node budget trip IS the graph budget trip" ); - // The in-flight node is terminally resolved — never a - // forever-Running node on a budget-dead graph. - assert_eq!(node_statuses(&actor)[0].1, NodeStatus::Failed); + // The in-flight node demotes to Ready — a budget trip is a + // resource stop, not a node verdict; a top-up re-runs it. + assert_eq!(node_statuses(&actor)[0].1, NodeStatus::Ready); assert_eq!(actor.graph_tracker.lock().current_node_id(), None); // A terminal graph no longer owns the engine: the user may @@ -648,7 +648,7 @@ async fn planning_invalid_twice_pauses_and_resume_replans() { // /graph resume re-plans; a now-valid artifact launches node 1. let (good_tx, _good_count) = spawn_graph_planner_coordinator(vec![chain_graph_json()]); actor.tool_context.subagent_event_tx = Some(good_tx); - match actor.resume_graph().await { + match actor.resume_graph(None).await { graph::GraphSetupOutcome::Inference { reminder, .. } => { assert!(reminder.contains("Graph node 1/4"), "{reminder}"); } @@ -1252,7 +1252,7 @@ async fn resume_after_cancelled_batch_demotes_orphaned_running_nodes() { // /graph resume must demote the orphans and re-dispatch — NOT // wedge-pause with "no runnable node". - match actor.resume_graph().await { + match actor.resume_graph(None).await { graph::GraphSetupOutcome::Inference { reminder, .. } => { assert!(reminder.contains("Node C"), "{reminder}"); } @@ -1400,3 +1400,222 @@ async fn batch_merges_real_worktrees_and_cleans_them_up() { .await; unsafe { std::env::remove_var(ENV_FLAG) }; } + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn budget_top_up_resumes_a_budget_limited_graph_to_completion() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let dag = diamond_graph_json(); + let (mut actor, _tmp, _prx) = make_graph_actor_detached().await; + let (coord_tx, _captured) = spawn_scripted_coordinator( + _tmp.path().to_path_buf(), + move |req| happy_reply(req, &dag), + false, + ); + actor.tool_context.subagent_event_tx = Some(coord_tx); + actor.graph_concurrency = 2; + // Tiny budget: the a+b batch spends 40 (4 spawns × 10) > 30, + // so the inter-batch gate trips before c. + let _ = actor.setup_graph("build the diamond", Some(30)).await; + assert_eq!( + actor.graph_tracker.lock().status(), + Some(GoalStatus::BudgetLimited) + ); + + // Plain resume must refuse with the top-up hint. + match actor.resume_graph(None).await { + graph::GraphSetupOutcome::Message(msg) => { + assert!(msg.contains("--budget"), "{msg}"); + } + graph::GraphSetupOutcome::Inference { .. } => { + panic!("budget-limited graph must not resume without a top-up") + } + } + + // Top-up resumes and reaches the serial tail (node c). + match actor.resume_graph(Some(1_000)).await { + graph::GraphSetupOutcome::Inference { reminder, .. } => { + assert!(reminder.contains("Node C"), "{reminder}"); + } + graph::GraphSetupOutcome::Message(msg) => { + panic!("top-up must resume the graph, got: {msg}") + } + } + // Finish c + gn-final serially. + for _ in 0..2 { + drive_node_goal_to_complete(&actor).await; + let _ = actor.run_graph_round_end().await; + } + assert_eq!( + actor.graph_tracker.lock().status(), + Some(GoalStatus::Complete) + ); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} + +// ── handle_prompt-level coverage (real interception wiring) ──────────── + +fn agent_text(n: &acp::SessionNotification) -> Option { + match &n.update { + acp::SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content { + acp::ContentBlock::Text(t) => Some(t.text.clone()), + _ => None, + }, + _ => None, + } +} + +fn capture_gateway( + mut gateway_rx: tokio::sync::mpsc::UnboundedReceiver, +) -> StdArc>> { + let sent = StdArc::new(tokio::sync::Mutex::new(Vec::new())); + let sent_for_task = sent.clone(); + tokio::task::spawn_local(async move { + while let Some(msg) = gateway_rx.recv().await { + if let kigi_acp_lib::AcpClientMessage::SessionNotification(args) = msg { + sent_for_task.lock().await.push(args.request); + let _ = args.response_tx.send(Ok(())); + } + } + }); + sent +} + +fn drain_replay( + actor: StdArc, + mut event_rx: tokio::sync::mpsc::UnboundedReceiver, +) { + let settings = actor.buffering_settings.clone(); + tokio::task::spawn_local(async move { + let mut replay_buffer = ReplayBuffer::new(settings); + while let Some(event) = event_rx.recv().await { + match event { + SessionEvent::Notification(notification) => { + if let Some((primary, secondary)) = replay_buffer.consume_chunk(notification) { + actor.emit_buffered(primary).await; + if let Some(extra) = secondary { + actor.emit_buffered(extra).await; + } + } + } + SessionEvent::FlushReplay { respond_to } => { + if let Some(notification) = replay_buffer.flush() { + actor.emit_buffered(notification).await; + } + if let Some(tx) = respond_to { + let _ = tx.send(()); + } + } + } + } + }); +} + +/// Actor whose `/graph` slash commands resolve through the REAL +/// `handle_prompt` path: `update_goal` registered (goal gate), +/// `graph_enabled` set, gateway capture + replay drainer wired. +async fn make_graph_turn_actor() -> ( + StdArc, + StdArc>>, +) { + let (gateway_tx, gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let sent = capture_gateway(gateway_rx); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (mut actor, event_rx) = + create_test_actor_ex(0, 256_000, 85, gateway_tx, persistence_tx).await; + *actor.agent.borrow_mut() = test_agent_with_goal_tool().await; + actor.goal_enabled = true; + actor.graph_enabled = true; + let actor = StdArc::new(actor); + drain_replay(actor.clone(), event_rx); + (actor, sent) +} + +async fn drive_terminal_slash( + actor: &StdArc, + sent: &StdArc>>, + prompt: &str, +) -> String { + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + actor.handle_prompt( + &format!("graph-slash-{}", prompt.replace([' ', '/'], "-")), + vec![acp::ContentBlock::Text(acp::TextContent::new( + prompt.to_string(), + ))], + PromptMode::Agent, + None, + None, + false, + None, + None, + ), + ) + .await + .expect("terminal slash outcome must end the turn without inference"); + assert!(result.is_ok(), "turn must succeed: {result:?}"); + tokio::task::yield_now().await; + let sent = sent.lock().await; + sent.iter() + .filter_map(agent_text) + .collect::>() + .join("\n") +} + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn graph_slash_terminal_outcomes_through_handle_prompt() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (actor, sent) = make_graph_turn_actor().await; + + // /graph status with no graph: terminal message, no inference. + let text = drive_terminal_slash(&actor, &sent, "/graph status").await; + assert!( + text.contains("No graph is set"), + "status message must reach the gateway: {text}" + ); + + // /graph resume with no graph. + let text = drive_terminal_slash(&actor, &sent, "/graph resume").await; + assert!(text.contains("No graph is set"), "{text}"); + + // Seed an Active graph that owns the engine; /goal commands + // must be refused through the REAL interception guards. + actor.graph_tracker.lock().create_graph( + "g-1".into(), + "obj".into(), + None, + "2026-01-01T00:00:00Z".into(), + ); + let text = drive_terminal_slash(&actor, &sent, "/goal pause").await; + assert!( + text.contains("graph owns the goal engine"), + "/goal pause refusal must surface: {text}" + ); + let text = drive_terminal_slash(&actor, &sent, "/goal clear").await; + assert!(text.contains("graph owns the goal engine"), "{text}"); + + // /graph pause on the planning-phase Active graph pauses it. + let text = drive_terminal_slash(&actor, &sent, "/graph pause").await; + assert!(text.contains("Graph paused"), "{text}"); + assert!( + actor + .graph_tracker + .lock() + .status() + .is_some_and(|s| s.is_paused()) + ); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} diff --git a/crates/codegen/kigi-shell/src/session/graph_tracker.rs b/crates/codegen/kigi-shell/src/session/graph_tracker.rs index 44ed117..632c953 100644 --- a/crates/codegen/kigi-shell/src/session/graph_tracker.rs +++ b/crates/codegen/kigi-shell/src/session/graph_tracker.rs @@ -562,8 +562,11 @@ impl GraphTracker { } /// `Active -> BudgetLimited`; `true` if the transition happened. - /// The in-flight node (if any) is terminally resolved to `Failed` so - /// a budget-dead graph never persists a forever-`Running` node. + /// Every in-flight node (the serial engine node AND any parallel + /// batch node) demotes to `Ready`: a budget trip is a resource + /// stop, not a verdict on the node — so no forever-`Running` node + /// is ever persisted, and a later budget top-up + /// ([`Self::resume_budget_limited`]) re-dispatches it naturally. pub fn budget_limit(&mut self) -> bool { self.account_elapsed(); let Some(state) = self.state.as_mut() else { @@ -573,12 +576,10 @@ impl GraphTracker { return false; } let in_flight = state.current_node.clone(); - if let Some(node_id) = &in_flight - && let Some(node) = state.nodes.iter_mut().find(|n| n.id == *node_id) - && !node.status.is_terminal() - { - node.status = NodeStatus::Failed; - node.failure = Some("graph token budget exhausted".to_owned()); + for node in &mut state.nodes { + if matches!(node.status, NodeStatus::Running | NodeStatus::Verifying) { + node.status = NodeStatus::Ready; + } } state.current_node = None; state.status = GoalStatus::BudgetLimited; @@ -591,6 +592,31 @@ impl GraphTracker { true } + /// `BudgetLimited -> Active` with `extra` fresh headroom: the new + /// budget becomes spent-so-far + extra. `true` if applied. + pub fn resume_budget_limited(&mut self, extra: i64) -> bool { + let Some(state) = self.state.as_mut() else { + return false; + }; + if state.status != GoalStatus::BudgetLimited || extra <= 0 { + return false; + } + state.token_budget = Some(state.tokens_spent_nodes.saturating_add(extra)); + state.status = GoalStatus::Active; + state.pause_message = None; + push_history( + state, + GraphHistoryEntry::now( + GraphEvent::GraphResumed, + None, + Some(format!("budget topped up by {extra}")), + ), + ); + self.last_probe = Some(Instant::now()); + self.recompute_ready(); + true + } + /// Drop all graph state (history records the clear first so a /// final persisted snapshot, if any, carries it). pub fn clear(&mut self) { @@ -860,27 +886,33 @@ mod tests { } #[test] - fn budget_limit_terminally_fails_the_running_node() { + fn budget_limit_demotes_in_flight_and_top_up_resumes() { let mut t = GraphTracker::new(std::env::temp_dir()); t.create_graph("g".into(), "o".into(), Some(10), "t".into()); t.install_nodes(vec![node("a", &[]), node("b", &["a"])]); t.mark_node_running("a", "goal-1".into()); + t.charge_node_tokens("a", 12); assert!(t.budget_limit()); assert_eq!(t.status(), Some(GoalStatus::BudgetLimited)); let s = t.snapshot().unwrap(); assert_eq!( s.nodes[0].status, - NodeStatus::Failed, - "a budget-dead graph must not persist a forever-Running node" - ); - assert!( - s.nodes[0] - .failure - .as_deref() - .unwrap() - .contains("budget exhausted") + NodeStatus::Ready, + "budget trip is a resource stop, not a node verdict — no \ + forever-Running node, runnable again after a top-up" ); assert_eq!(s.current_node, None); + + assert!(!t.resume_budget_limited(0), "top-up must be positive"); + assert!(t.resume_budget_limited(100)); + assert_eq!(t.status(), Some(GoalStatus::Active)); + assert_eq!( + t.snapshot().unwrap().token_budget, + Some(112), + "new budget = spent so far + extra headroom" + ); + assert_eq!(t.remaining_budget(), Some(100)); + assert_eq!(t.next_ready_node().unwrap().id, "a"); } #[test] diff --git a/crates/codegen/kigi-shell/src/session/slash_commands.rs b/crates/codegen/kigi-shell/src/session/slash_commands.rs index b1677b5..9f1e522 100644 --- a/crates/codegen/kigi-shell/src/session/slash_commands.rs +++ b/crates/codegen/kigi-shell/src/session/slash_commands.rs @@ -263,7 +263,9 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[ BuiltinCommand { name: "graph", description: "Decompose an objective into a dependency graph of autonomous goals", - argument_hint: Some(" [--budget ] | status | pause | resume | clear"), + argument_hint: Some( + " [--budget ] | status | pause | resume [--budget ] | clear", + ), aliases: &[], gate: BuiltinGate::Graph, resolve: |args| { @@ -273,9 +275,27 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[ // it is an alias for the status tree. "" | "status" | "show" => BuiltinAction::GraphStatus, "pause" => BuiltinAction::GraphPause, - "resume" => BuiltinAction::GraphResume, + "resume" => BuiltinAction::GraphResume { extra_budget: None }, "clear" => BuiltinAction::GraphClear, _ => { + // ANY input starting with `resume` is a resume attempt + // and must NEVER fall through to GraphSet — a typo'd + // top-up would otherwise silently replace a resumable + // BudgetLimited graph. Well-formed `resume --budget + // ` (case-insensitive keywords) carries the + // top-up; malformed variants resolve to a plain + // resume, whose BudgetLimited arm prints the usage. + let lower = trimmed.to_lowercase(); + if let Some(rest) = lower.strip_prefix("resume") { + let extra_budget = rest + .trim() + .strip_prefix("--budget") + .map(str::trim) + .filter(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit())) + .and_then(|v| v.parse::().ok()) + .filter(|extra| *extra > 0); + return BuiltinAction::GraphResume { extra_budget }; + } let (objective, token_budget) = parse_goal_budget(trimmed); BuiltinAction::GraphSet { objective, @@ -700,7 +720,9 @@ pub(super) enum BuiltinAction { }, GraphStatus, GraphPause, - GraphResume, + GraphResume { + extra_budget: Option, + }, GraphClear, } @@ -737,7 +759,7 @@ impl BuiltinAction { BuiltinAction::GraphSet { .. } | BuiltinAction::GraphStatus | BuiltinAction::GraphPause - | BuiltinAction::GraphResume + | BuiltinAction::GraphResume { .. } | BuiltinAction::GraphClear => "graph", } } @@ -772,10 +794,10 @@ impl BuiltinAction { | BuiltinAction::GoalResume | BuiltinAction::GoalClear => false, BuiltinAction::GraphSet { .. } => true, - BuiltinAction::GraphStatus - | BuiltinAction::GraphPause - | BuiltinAction::GraphResume - | BuiltinAction::GraphClear => false, + BuiltinAction::GraphResume { extra_budget } => extra_budget.is_some(), + BuiltinAction::GraphStatus | BuiltinAction::GraphPause | BuiltinAction::GraphClear => { + false + } } } } @@ -1714,7 +1736,26 @@ mod tests { )); assert!(matches!( resolve_builtin("graph", "resume"), - Some(BuiltinAction::GraphResume) + Some(BuiltinAction::GraphResume { extra_budget: None }) + )); + assert!(matches!( + resolve_builtin("graph", "resume --budget 800"), + Some(BuiltinAction::GraphResume { + extra_budget: Some(800) + }) + )); + // Malformed top-ups resolve to a PLAIN resume — never to + // GraphSet, which would silently replace a resumable graph; the + // BudgetLimited resume arm then prints the usage hint. + assert!(matches!( + resolve_builtin("graph", "resume --budget nope"), + Some(BuiltinAction::GraphResume { extra_budget: None }) + )); + assert!(matches!( + resolve_builtin("graph", "Resume --Budget 800"), + Some(BuiltinAction::GraphResume { + extra_budget: Some(800) + }) )); assert!(matches!( resolve_builtin("graph", "clear"), diff --git a/crates/codegen/kigi-tui/src/app/acp_handler/session_notification.rs b/crates/codegen/kigi-tui/src/app/acp_handler/session_notification.rs index f90b316..b2ca4b8 100644 --- a/crates/codegen/kigi-tui/src/app/acp_handler/session_notification.rs +++ b/crates/codegen/kigi-tui/src/app/acp_handler/session_notification.rs @@ -977,6 +977,37 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu XaiSessionUpdate::InteractionResolved { tool_call_id } => { agent.dismiss_resolved_interaction(&tool_call_id) } + XaiSessionUpdate::GraphUpdated { + objective, + status, + total_nodes, + achieved_nodes, + failed_nodes, + running_nodes, + current_node_title, + token_budget, + tokens_spent, + pause_message, + .. + } => { + if status == "cleared" { + agent.graph_state.take(); + } else { + agent.graph_state = Some(crate::app::agent::GraphDisplayState { + objective, + status: crate::app::agent::GoalDisplayStatus::parse(&status), + total_nodes, + achieved_nodes, + failed_nodes, + running_nodes, + current_node_title, + token_budget, + tokens_spent, + pause_message, + }); + } + true + } _ => { tracing::trace!( "Ignoring {}: {:?}", diff --git a/crates/codegen/kigi-tui/src/app/agent.rs b/crates/codegen/kigi-tui/src/app/agent.rs index 1c6a6cc..b052f64 100644 --- a/crates/codegen/kigi-tui/src/app/agent.rs +++ b/crates/codegen/kigi-tui/src/app/agent.rs @@ -385,6 +385,25 @@ impl GoalDisplayPhase { } } } +/// Graph mode display state — the pager-side mirror of the +/// `GraphUpdated` session notification (see the shell's +/// `extensions/notification.rs`). Deliberately lean: the status chip +/// shows counts + the current node; details live in `/graph status`. +#[derive(Debug, Clone)] +pub struct GraphDisplayState { + pub objective: String, + /// Reuses the goal display-status vocabulary (same wire strings). + pub status: GoalDisplayStatus, + pub total_nodes: u32, + pub achieved_nodes: u32, + pub failed_nodes: u32, + pub running_nodes: u32, + pub current_node_title: Option, + pub token_budget: Option, + pub tokens_spent: i64, + pub pause_message: Option, +} + /// Display state for an active goal, populated from `GoalUpdated` /// session notifications emitted by the goal orchestrator. #[derive(Debug, Clone)] diff --git a/crates/codegen/kigi-tui/src/app/agent_view/mod.rs b/crates/codegen/kigi-tui/src/app/agent_view/mod.rs index ec35b62..0d5eff0 100644 --- a/crates/codegen/kigi-tui/src/app/agent_view/mod.rs +++ b/crates/codegen/kigi-tui/src/app/agent_view/mod.rs @@ -745,6 +745,9 @@ pub struct AgentView { /// Current goal orchestration state. Set by `GoalUpdated` session /// notifications, cleared when a new session starts. pub goal_state: Option, + /// Current graph orchestration state. Set by `GraphUpdated` session + /// notifications, cleared on the `"cleared"` sentinel / new session. + pub graph_state: Option, /// The consumed parked-wait marker slot for the current turn, if any. /// Keyed by prompt id: a new turn naturally invalidates the slot with no /// explicit clear site. See [`ParkedMarkerSlot`]. diff --git a/crates/codegen/kigi-tui/src/app/agent_view/render.rs b/crates/codegen/kigi-tui/src/app/agent_view/render.rs index 15fe1f7..16053d3 100644 --- a/crates/codegen/kigi-tui/src/app/agent_view/render.rs +++ b/crates/codegen/kigi-tui/src/app/agent_view/render.rs @@ -1243,6 +1243,13 @@ impl AgentView { ), ); } + if let Some(ref graph) = self.graph_state { + let tick = self.tasks.tick_count() as usize; + status.push( + "graph", + crate::views::agent_status::graph_status_line(graph, &theme, tick), + ); + } if let Some(mcp_line) = self.mcp_init_progress.as_ref().and_then(|p| { crate::views::agent_status::mcp_status_line(p, self.scrollback.animation_tick(), &theme) }) { diff --git a/crates/codegen/kigi-tui/src/app/agent_view/session.rs b/crates/codegen/kigi-tui/src/app/agent_view/session.rs index ca84713..0b2e1ba 100644 --- a/crates/codegen/kigi-tui/src/app/agent_view/session.rs +++ b/crates/codegen/kigi-tui/src/app/agent_view/session.rs @@ -93,6 +93,7 @@ impl AgentView { chat_kind: false, app_chat_mode: false, goal_state: None, + graph_state: None, parked_wait_marker_for: None, end_work_announced: false, pending_stop_hooks: None, diff --git a/crates/codegen/kigi-tui/src/views/agent_status.rs b/crates/codegen/kigi-tui/src/views/agent_status.rs index 64a683f..eb40762 100644 --- a/crates/codegen/kigi-tui/src/views/agent_status.rs +++ b/crates/codegen/kigi-tui/src/views/agent_status.rs @@ -282,6 +282,75 @@ pub fn goal_status_line( ]) } +// --------------------------------------------------------------------------- +// Graph status chip +// --------------------------------------------------------------------------- + +/// Build the compact `/graph` status chip: node progress, the current +/// node, and spend. Same chip idiom as [`goal_status_line`] — dim +/// brackets, paused chips invert onto `theme.warning`, active chips +/// animate. +pub fn graph_status_line( + graph: &crate::app::agent::GraphDisplayState, + theme: &Theme, + tick: usize, +) -> Line<'static> { + let dim_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base); + let label_style = if graph.status.is_paused() { + Style::default().fg(theme.bg_base).bg(theme.warning) + } else { + Style::default().fg(theme.accent_plan).bg(theme.bg_base) + }; + let is_active = matches!(graph.status, GoalDisplayStatus::Active); + + let mut progress = format!("{}/{}", graph.achieved_nodes, graph.total_nodes); + if graph.failed_nodes > 0 { + progress.push_str(&format!(" ({} failed)", graph.failed_nodes)); + } + // Planner titles are uncapped; clamp so one long title can't push + // the whole status bar off-screen. + let clamped_title = graph.current_node_title.as_deref().map(|t| { + if t.chars().count() > 40 { + let head: String = t.chars().take(39).collect(); + format!("{head}…") + } else { + t.to_owned() + } + }); + let label = match (&graph.status, clamped_title.as_deref()) { + (GoalDisplayStatus::Active, Some(title)) => format!("{progress} · {title}"), + (GoalDisplayStatus::Active, None) if graph.running_nodes > 1 => { + format!("{progress} · {} nodes in flight", graph.running_nodes) + } + (GoalDisplayStatus::Complete, _) => format!("{progress} · complete"), + (GoalDisplayStatus::BudgetLimited, _) => format!("{progress} · budget limit"), + (status, _) if status.is_paused() => format!("{progress} · paused"), + _ => progress, + }; + let graph_text = if is_active { + let frames = crate::glyphs::dot_spinner_frames(); + let frame = frames[(tick / 4) % frames.len()]; + format!("{frame} Graph: {label}") + } else { + format!("Graph: {label}") + }; + + let tokens_str = format_tokens_compact(graph.tokens_spent.max(0)); + let tokens_display = match graph.token_budget { + Some(budget) if budget > 0 => { + format!("{}/{} tokens", tokens_str, format_tokens_compact(budget)) + } + _ => format!("{tokens_str} tokens"), + }; + + Line::from(vec![ + Span::styled("[", dim_style), + Span::styled(graph_text, label_style), + Span::styled("]", dim_style), + Span::styled(format!(" {tokens_display}"), dim_style), + ]) +} + // --------------------------------------------------------------------------- // MCP connecting indicator // --------------------------------------------------------------------------- diff --git a/crates/codegen/kigi-tui/tests/scenarios/graph_slash_presession.yaml b/crates/codegen/kigi-tui/tests/scenarios/graph_slash_presession.yaml new file mode 100644 index 0000000..7f570cc --- /dev/null +++ b/crates/codegen/kigi-tui/tests/scenarios/graph_slash_presession.yaml @@ -0,0 +1,40 @@ +name: graph-slash-presession +description: > + With the graph feature flag on (`KIGI_GRAPH=1`, plus `KIGI_GOAL=1` — the + graph gate requires the goal harness), `/graph` must appear in the + slash-command menu on the welcome screen *before* the first user turn + creates a session. Typing `/graph` pre-session must surface the command + and its description in the dropdown. +terminal: + rows: 40 + cols: 100 +environment: + env: + - key: KIGI_GOAL + value: "1" + - key: KIGI_GRAPH + value: "1" +mock: + response: "unused — this scenario never submits a prompt." +steps: + - action: wait_for_text + text: Quit + timeout_ms: 20000 + - action: assert_not_contains + text: panicked + # Sanity: nothing has been submitted yet, so no session exists. + - action: assert_not_contains + text: dependency graph + - action: focus_prompt + - action: type_text + text: "/graph" + - action: wait + millis: 300 + # The dropdown row carries the builtin's description, which only renders + # when /graph is actually advertised pre-session. + - action: assert_contains + text: dependency graph + - action: assert_running + - action: screenshot + name: graph-in-slash-menu-presession + note: "/graph advertised in the slash menu on the welcome screen (KIGI_GRAPH=1), before any prompt." diff --git a/crates/codegen/kigi-tui/tests/scenarios/graph_slash_presession_disabled.yaml b/crates/codegen/kigi-tui/tests/scenarios/graph_slash_presession_disabled.yaml new file mode 100644 index 0000000..64b8a67 --- /dev/null +++ b/crates/codegen/kigi-tui/tests/scenarios/graph_slash_presession_disabled.yaml @@ -0,0 +1,45 @@ +name: graph-slash-presession-disabled +description: > + Fail-closed counterpart: with `KIGI_GRAPH=0` (goal harness still on), + `/graph` must NOT appear in the pre-session slash menu, while the menu + itself keeps working for other commands. +terminal: + rows: 40 + cols: 100 +environment: + env: + - key: KIGI_GOAL + value: "1" + - key: KIGI_GRAPH + value: "0" +mock: + response: "unused — this scenario never submits a prompt." +steps: + - action: wait_for_text + text: Quit + timeout_ms: 20000 + - action: assert_not_contains + text: panicked + - action: focus_prompt + - action: type_text + text: "/graph" + - action: wait + millis: 300 + - action: assert_not_contains + text: dependency graph + - action: assert_running + - action: screenshot + name: graph-absent-from-slash-menu + note: "/graph hidden with KIGI_GRAPH=0 (fail-closed gate)." + # The menu itself still works: clear "/graph" (6 chars) and try /compact. + - action: keys + keys: "" + - action: type_text + text: "/compact" + - action: wait + millis: 300 + - action: assert_contains + text: Compact conversation history + - action: screenshot + name: slash-menu-still-works + note: "Slash menu functional; only /graph is gated off." diff --git a/crates/codegen/kigi-tui/tests/scripted_scenarios.rs b/crates/codegen/kigi-tui/tests/scripted_scenarios.rs index fdbf88b..0d8fa48 100644 --- a/crates/codegen/kigi-tui/tests/scripted_scenarios.rs +++ b/crates/codegen/kigi-tui/tests/scripted_scenarios.rs @@ -339,6 +339,21 @@ async fn scripted_goal_slash_presession() { run_scenario("goal_slash_presession.yaml").await; } +/// `/graph` gating mirror of the `/goal` pre-session scenarios: with +/// `KIGI_GRAPH=1` (+ the goal harness) the command must be advertised on +/// the welcome screen before any session exists. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "scripted scenario; run with cargo test -- --ignored"] +async fn scripted_graph_slash_presession() { + run_scenario("graph_slash_presession.yaml").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "scripted scenario; run with cargo test -- --ignored"] +async fn scripted_graph_slash_presession_disabled() { + run_scenario("graph_slash_presession_disabled.yaml").await; +} + /// Counterpart to `scripted_goal_slash_presession`: with the goal flag /// explicitly off (`KIGI_GOAL=0`; goal mode defaults on), `/goal` must stay /// hidden pre-session (gate fail-closed) while an @@ -516,6 +531,8 @@ fn scenarios_parse() { "path_space_hyperlink.yaml", "goal_slash_presession.yaml", "goal_slash_presession_disabled.yaml", + "graph_slash_presession.yaml", + "graph_slash_presession_disabled.yaml", "folder_trust_prompt.yaml", "dashboard_model_list_click.yaml", "paste_chip_double_click.yaml",