Add /graph G2: resumable budget, GraphUpdated status chip, PTY + turn-level coverage
BudgetLimited is now a resumable state: a budget trip demotes in-flight
nodes to Ready (a resource stop, not a verdict — no forever-Running node is
ever persisted) and '/graph resume --budget <tokens>' re-arms the graph with
fresh headroom (new budget = spent-so-far + extra). The tripped node's
partial burn is charged into tokens_spent_nodes at BOTH cascade sites before
the demotion clears current_node, so the top-up arithmetic never runs on an
under-counted ledger. Any input starting with 'resume' resolves to a resume
(case-insensitive; malformed top-ups surface the usage hint) and setup_graph
refuses to replace any non-Complete graph — a typo can no longer silently
destroy a resumable graph. An explicit --budget on a merely-paused graph is
rejected loudly instead of silently discarded; all trip-time messages now
advertise the top-up.
The pager gains a graph status chip: a new GraphUpdated wire variant
(extensions/notification.rs, old pagers degrade via #[serde(other)]) is
emitted from the single persist_graph_state chokepoint — every transition is
both a checkpoint and a badge tick — with a 'cleared' sentinel on /graph
clear and a one-shot re-emit after session restore (the replayed updates log
otherwise shows the pre-shutdown Active state that from_snapshot just
demoted in memory). TUI side: GraphDisplayState, session-notification arm,
and a goal-idiom chip with node progress, clamped current-node title, and
budget-aware spend. Pre-session command availability now advertises /graph
from the flags (it was fail-closed to the in-session path only, so the
welcome-screen slash menu never showed it).
Coverage: GraphUpdated wire round-trip + minimal-payload + unknown-tag
tests; PTY scenarios graph_slash_presession{,_disabled}.yaml (both run
green against the real pager binary); handle_prompt-level e2e for terminal
slash outcomes (/graph status|resume|pause, /goal refusals while the graph
owns the engine); budget top-up e2e driving a BudgetLimited diamond back to
Complete. Not shimmed: pre-G2 persisted snapshots with budget-Failed nodes
(the KIGI_GRAPH flag has never shipped enabled, so none exist).
kigi-shell 4927 and kigi-tui 6610 lib tests green; workspace clippy clean.
This commit is contained in:
@@ -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 {}: {:?}",
|
||||
|
||||
@@ -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<String>,
|
||||
pub token_budget: Option<i64>,
|
||||
pub tokens_spent: i64,
|
||||
pub pause_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Display state for an active goal, populated from `GoalUpdated`
|
||||
/// session notifications emitted by the goal orchestrator.
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -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<super::agent::GoalDisplayState>,
|
||||
/// Current graph orchestration state. Set by `GraphUpdated` session
|
||||
/// notifications, cleared on the `"cleared"` sentinel / new session.
|
||||
pub graph_state: Option<super::agent::GraphDisplayState>,
|
||||
/// 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`].
|
||||
|
||||
@@ -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)
|
||||
}) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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."
|
||||
@@ -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: "<BS><BS><BS><BS><BS><BS>"
|
||||
- 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."
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user