Files
Kigi-CLI/crates/codegen/kigi-shell/src/extensions/notification.rs
T
ZacharyZhang-NY 1579558b56 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.
2026-07-20 16:35:02 -04:00

2411 lines
100 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use agent_client_protocol as acp;
use kigi_tools::types::TaskSnapshot;
use crate::session::feedback::FeedbackRequest as FeedbackRequestData;
pub use crate::session::goal_tracker::GoalClassifierVerdict;
/// Retained for wire backwards compatibility; always empty in the
/// simplified goal model (no deliverables).
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct GoalDeliverableInfo {
pub id: u32,
pub title: String,
pub status: String,
}
/// xAI-specific session notification (parallel to acp::SessionNotification)
/// This wraps an XaiSessionUpdate with session context for persistence and replay.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNotification {
/// The ID of the session this update pertains to.
pub session_id: acp::SessionId,
/// The actual update content.
pub update: SessionUpdate,
/// Extension point for implementations
#[serde(skip_serializing_if = "Option::is_none", rename = "_meta")]
pub meta: Option<serde_json::Value>,
}
/// Wire usage for ACP `_meta.usage` and `TurnCompleted.usage`.
///
/// # Wire contract (ACP vs headless)
///
/// | Surface | `input_tokens` / `inputTokens` | Cost |
/// |---------|--------------------------------|------|
/// | **ACP** (`PromptUsage`) | **Full** prompt sum (includes cache reads) | `costUsdTicks` (1e10 ticks = $1), scrubbed when partial/incomplete |
/// | **Headless** ([`project_result_usage`]) | **Uncached only** (`full cache_read`) | Float `total_cost_usd` + exact `total_cost_usd_ticks`, only when complete |
/// | ACP `_meta` sibling fields | **Last model call only** (not whole-prompt) | — |
///
/// Trust cost only when present **and** not `usageIsIncomplete` **and** not
/// `costIsPartial`. Absence of cost means untrustworthy or unknown — not free.
///
/// Mixed headless shape is frozen for external-tool compatibility: snake_case
/// on totals (`usage.input_tokens`, `total_cost_usd`) and camelCase under
/// `modelUsage` (`inputTokens`, `costUSD`). Per-model rows are a reduced
/// schema (no reasoning/duration on the wire).
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PromptUsage {
#[serde(flatten)]
pub totals: PromptUsageModel,
#[serde(
default,
rename = "modelUsage",
skip_serializing_if = "indexmap::IndexMap::is_empty"
)]
pub model_usage: indexmap::IndexMap<String, PromptUsageModel>,
/// Main-agent loop rounds (same unit as `--max-turns`).
#[serde(default, rename = "numTurns")]
pub num_turns: u64,
/// Bill may under-count (open subagents, usage not applied, or drain timeout).
#[serde(
default,
rename = "usageIsIncomplete",
skip_serializing_if = "std::ops::Not::not"
)]
pub usage_is_incomplete: bool,
}
impl PromptUsage {
/// Project a ledger snapshot for the wire. Returns `Some` whenever
/// `incomplete` is set — even if `ledger` is `None` — so the flag is never
/// dropped by omission. Always scrubs untrustworthy costs.
pub fn project_from_ledger(
ledger: Option<&kigi_chat_state::UsageLedger>,
incomplete: bool,
) -> Option<Self> {
let mut usage = match ledger {
Some(ledger) => {
let mut usage = Self::from(ledger);
if incomplete {
usage.usage_is_incomplete = true;
}
usage
}
None if incomplete => Self {
usage_is_incomplete: true,
..Default::default()
},
None => return None,
};
usage.scrub_untrustworthy_costs();
Some(usage)
}
/// Error-path attach: any open ledger is always incomplete (may under-count
/// without a freeze drain). `may_undercount` only matters when the ledger is empty.
pub fn for_error_path(
ledger: Option<&kigi_chat_state::UsageLedger>,
may_undercount: bool,
) -> Option<Self> {
match (ledger, may_undercount) {
(Some(l), _) => Self::project_from_ledger(Some(l), true),
(None, true) => Self::project_from_ledger(None, true),
(None, false) => None,
}
}
/// Drop cost ticks when partial or incomplete so all wire surfaces fail closed.
/// Incomplete bills clear ticks even when `cost_is_partial` is false.
pub fn scrub_untrustworthy_costs(&mut self) {
if !(self.usage_is_incomplete || self.totals.cost_is_partial) {
return;
}
self.totals.cost_usd_ticks = None;
for m in self.model_usage.values_mut() {
m.cost_usd_ticks = None;
if self.totals.cost_is_partial {
m.cost_is_partial = true;
}
}
}
fn is_token_empty(&self) -> bool {
// Exhaustive destructure: a new token field must decide whether it
// counts as "billed something" here.
let PromptUsageModel {
input_tokens,
output_tokens,
total_tokens: _, // derived from input + output
cached_read_tokens,
reasoning_tokens: _, // subset of output_tokens
model_calls,
api_duration_ms: _, // timing, not tokens
cost_usd_ticks: _, // cost without usage cannot occur
cost_is_partial: _,
cost_missing_calls: _,
} = self.totals;
model_calls == 0
&& input_tokens == 0
&& output_tokens == 0
&& cached_read_tokens == 0
&& self.model_usage.is_empty()
}
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptUsageModel {
/// Full prompt input tokens including cache reads (ACP identity).
/// Headless projects uncached only — see [`project_result_usage`].
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
#[serde(default)]
pub total_tokens: u64,
#[serde(default)]
pub cached_read_tokens: u64,
#[serde(default)]
pub reasoning_tokens: u64,
#[serde(default)]
pub model_calls: u64,
#[serde(default)]
pub api_duration_ms: u64,
/// Server cost in USD ticks (`USD_TICKS_PER_USD` = 1e10 ticks per $1).
/// Absent when scrubbed, missing, or zero on the wire. Headless projects
/// the totals as float `total_cost_usd` (plus exact `total_cost_usd_ticks`)
/// and per-model rows as float `costUSD`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_usd_ticks: Option<i64>,
/// Some folded calls lacked cost, so any cost shown is a partial sum.
/// After a scrub of a partial bill, complete per-model rows are also
/// stamped `true`: the flag means "do not trust this row's cost", not
/// "this row's own cost was partial".
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub cost_is_partial: bool,
/// How many calls reported usage but no cost. Internal accounting for
/// `cost_is_partial` only — never on the public ACP wire.
#[serde(default, skip_serializing)]
pub cost_missing_calls: u64,
}
impl From<&kigi_chat_state::UsageTotals> for PromptUsageModel {
fn from(t: &kigi_chat_state::UsageTotals) -> Self {
// Exhaustive destructure: a new ledger field cannot silently miss the
// wire. When one is added here, also extend `project_result_usage`.
let kigi_chat_state::UsageTotals {
input_tokens,
output_tokens,
cached_read_tokens,
reasoning_tokens,
model_calls,
api_duration_ms,
cost_usd_ticks,
cost_missing_calls,
} = *t;
Self {
input_tokens,
output_tokens,
total_tokens: t.total_tokens(),
cached_read_tokens,
reasoning_tokens,
model_calls,
api_duration_ms,
cost_usd_ticks,
cost_is_partial: t.cost_is_partial(),
cost_missing_calls,
}
}
}
impl From<&kigi_chat_state::UsageLedger> for PromptUsage {
fn from(ledger: &kigi_chat_state::UsageLedger) -> Self {
let mut usage = Self {
totals: PromptUsageModel::from(&ledger.totals),
model_usage: ledger
.by_model
.iter()
.map(|(k, v)| (k.clone(), PromptUsageModel::from(v)))
.collect(),
num_turns: ledger.main_loop_model_calls,
usage_is_incomplete: ledger.incomplete,
};
usage.scrub_untrustworthy_costs();
usage
}
}
/// Server cost scale: 1 USD = 10^10 ticks. ACP exposes ticks; headless converts to float USD.
pub const USD_TICKS_PER_USD: f64 = 1e10;
/// Convert server cost ticks to float USD (headless only).
pub fn ticks_to_usd(ticks: i64) -> f64 {
ticks as f64 / USD_TICKS_PER_USD
}
/// Full ACP input → headless uncached input (`full cache_read`).
pub fn uncached_input_tokens(full_input: u64, cached_read: u64) -> u64 {
full_input.saturating_sub(cached_read)
}
/// Project usage onto a headless result object.
///
/// - `usage.input_tokens` = uncached (`full cache_read`); identity
/// `uncached + cache_read + output = total_tokens`.
/// - Omits all cost floats when partial or incomplete (absence ≠ free).
/// - Incomplete with no tokens emits only `usage_is_incomplete` (no zero usage object).
/// - `modelUsage` rows are a reduced external-compat schema (camelCase; no reasoning/duration).
pub fn project_result_usage(result: &mut serde_json::Value, usage: &PromptUsage) {
if usage.usage_is_incomplete && usage.is_token_empty() {
result["usage_is_incomplete"] = true.into();
return;
}
// Exhaustive destructure: a new wire field is a compile error until it is
// either projected or named as deliberately dropped from the headless shape.
let PromptUsageModel {
input_tokens,
output_tokens,
total_tokens,
cached_read_tokens,
reasoning_tokens,
model_calls: _, // totals-level; headless carries num_turns instead
api_duration_ms: _, // dropped: not part of the frozen headless shape
cost_usd_ticks,
cost_is_partial,
cost_missing_calls: _, // internal partiality count; the flag suffices
} = usage.totals;
result["usage"] = serde_json::json!({
"input_tokens": uncached_input_tokens(input_tokens, cached_read_tokens),
"cache_read_input_tokens": cached_read_tokens,
"output_tokens": output_tokens,
"reasoning_tokens": reasoning_tokens,
"total_tokens": total_tokens,
});
result["num_turns"] = usage.num_turns.into();
if usage.usage_is_incomplete {
result["usage_is_incomplete"] = true.into();
}
let hide_costs = cost_is_partial || usage.usage_is_incomplete;
if hide_costs {
if cost_is_partial {
result["cost_is_partial"] = true.into();
}
} else if let Some(ticks) = cost_usd_ticks {
result["total_cost_usd"] = serde_json::json!(ticks_to_usd(ticks));
// Exact integer ticks beside the float, under the same trust gate:
// reconciliation sums ticks exactly, which floats cannot guarantee.
result["total_cost_usd_ticks"] = serde_json::json!(ticks);
}
if !usage.model_usage.is_empty() {
let mut model_usage = serde_json::Map::new();
for (name, m) in &usage.model_usage {
let PromptUsageModel {
input_tokens,
output_tokens,
total_tokens: _, // derivable per row
cached_read_tokens,
reasoning_tokens: _, // dropped: reduced per-model schema
model_calls,
api_duration_ms: _, // dropped: reduced per-model schema
cost_usd_ticks,
cost_is_partial,
cost_missing_calls: _,
} = *m;
let mut entry = serde_json::json!({
"inputTokens": uncached_input_tokens(input_tokens, cached_read_tokens),
"outputTokens": output_tokens,
"cacheReadInputTokens": cached_read_tokens,
"modelCalls": model_calls,
});
if !hide_costs
&& let Some(ticks) = cost_usd_ticks
&& !cost_is_partial
{
entry["costUSD"] = serde_json::json!(ticks_to_usd(ticks));
}
model_usage.insert(name.clone(), entry);
}
result["modelUsage"] = model_usage.into();
}
}
/// Fail-closed attach for headless results: parse failure becomes
/// `usage_is_incomplete` (never omit silently — absence must not look free).
pub fn attach_result_usage_fail_closed(result: &mut serde_json::Value, usage: &serde_json::Value) {
match serde_json::from_value::<PromptUsage>(usage.clone()) {
Ok(parsed) => project_result_usage(result, &parsed),
Err(e) => {
tracing::warn!(
error = %e,
"headless: _meta.usage failed to parse; marking usage_is_incomplete"
);
result["usage_is_incomplete"] = true.into();
}
}
}
/// Status of a single hook run (wire format).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", tag = "status")]
pub enum HookRunStatusDto {
Success { elapsed_ms: u64 },
Skipped,
Failed { error: String, elapsed_ms: u64 },
}
/// A single hook run entry (wire format).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct HookRunEntryDto {
pub name: String,
pub status: HookRunStatusDto,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "sessionUpdate")]
pub enum SessionUpdate {
/// A diff review request containing one or more file diffs for user review.
DiffReview {
/// The diff content to be reviewed.
content: Vec<DiffContent>,
},
/// Notification that a retry is in progress due to a transient error.
RetryState(RetryState),
/// Auto-compact is starting due to context window threshold
AutoCompactStarted {
/// Current token usage
tokens_used: u64,
/// Total context window size
context_window: u64,
/// Percentage used (e.g., 82)
percentage: u8,
/// Reason for compaction
reason: String,
},
/// Auto-compact completed successfully
AutoCompactCompleted {
/// Tokens used before compaction. `None` on payloads from older shells.
#[serde(default, skip_serializing_if = "Option::is_none")]
tokens_before: Option<u64>,
/// Tokens used after compaction
tokens_after: u64,
/// How long the compaction took (milliseconds)
#[serde(skip_serializing_if = "Option::is_none")]
elapsed_ms: Option<i64>,
/// Summary preview (first ~100 chars of summary)
summary_preview: Option<String>,
},
/// Auto-compact failed
AutoCompactFailed {
/// Error message
error: String,
},
/// Memory flush is starting before compaction
MemoryFlushStarted,
/// Memory flush completed
MemoryFlushCompleted {
/// Outcome description
result: String,
/// Path to the written memory file (if any)
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<String>,
},
/// Memory dream consolidation completed
MemoryDreamCompleted {
/// Outcome description
result: String,
/// Path to the written memory file (if any)
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<String>,
},
/// Session-end memory save completed
MemorySessionSaved {
/// Path to the written session log
path: String,
},
/// Auto-compact was cancelled (user pressed Ctrl+C)
AutoCompactCancelled {
/// Reason for cancellation
reason: String,
},
/// Auto-continue completed after compaction
/// This signals the TUI to flush pending agent messages and end the turn
AutoContinueCompleted {
/// Total tokens used after auto-continue
total_tokens: u64,
},
/// Request for user feedback based on session heuristics
FeedbackRequest(FeedbackRequestNotification),
/// Auto-recovery is starting after a prompt failure (e.g. remote/workspace recovery)
AutoRecoveryStarted {
/// Current recovery attempt number (1-indexed)
attempt: u32,
/// Maximum number of recovery attempts allowed
max_retries: u32,
/// The error that triggered recovery
error: String,
/// Delay in milliseconds before the retry
delay_ms: u64,
},
/// Auto-recovery exhausted all retries and the turn is failing
AutoRecoveryExhausted {
/// Total attempts made
attempts: u32,
/// The final error message
error: String,
},
/// A hook annotation message for the TUI scrollback.
/// Rendered inline with the preceding tool call block.
HookAnnotation {
/// The hook message to display (e.g., "🪝 Running post_tool_use hooks for `Edit`...")
message: String,
},
/// Structured hook execution data attached to tool call blocks.
HookExecution {
/// The hook event name ("pre_tool_use" or "post_tool_use").
event_name: String,
/// The tool name this hook is associated with.
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_name: Option<String>,
/// The prompt turn this batch belongs to, when known; lets the
/// client keep a delayed `stop`/`stop_failure` batch off the wrong
/// turn's marker.
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt_id: Option<String>,
/// Individual hook run results.
runs: Vec<HookRunEntryDto>,
},
/// Hooks registry changed (after reload or trust/untrust).
/// Sent so the pager modal can auto-refresh if open.
HooksChanged {
hooks: Vec<kigi_hooks_plugins_types::HookInfo>,
project_trusted: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
load_errors: Vec<String>,
},
/// Plugins registry changed (after reload).
/// Sent so the pager modal can auto-refresh if open.
PluginsChanged {
plugins: Vec<kigi_hooks_plugins_types::PluginInfo>,
},
/// Marketplace plugin updates were auto-installed on session start.
/// Sent so desktop/pager can show a notification to the user.
PluginUpdatesInstalled {
/// List of (plugin_name, old_version, new_version).
updates: Vec<(String, String, String)>,
},
/// Session summary was generated for a new session.
/// Sent after the first user prompt when the LLM generates a title.
SessionSummaryGenerated {
/// The generated session summary/title
session_summary: String,
},
/// A short "where was I" recap of the session so far.
///
/// Emitted by the `kigi/recap` ext method: on demand via the `/recap`
/// slash command (`auto = false`), or automatically when the user
/// returns to the terminal after being away (`auto = true`). The pager
/// renders it as an informational scrollback line; it is never added to
/// the model conversation.
SessionRecap {
/// The one-line recap text (~2540 words; capped at a generous safety
/// limit, so a normal recap is shown in full).
summary: String,
/// `true` when generated automatically on return-from-away,
/// `false` for an explicit `/recap`.
#[serde(default)]
auto: bool,
},
/// A manual `/recap` produced no recap — no assistant turns yet, a failed
/// prepare/model call, or an empty summary. The pager shows a loading
/// spinner for `/recap`, so without this signal that spinner would animate
/// forever; on receipt the pager clears it. Never emitted for an automatic
/// recap (those show no spinner).
SessionRecapUnavailable,
/// A compaction checkpoint marker written to `updates.jsonl`.
///
/// This is **persist-only** — it is never sent to the gateway/UI. It records
/// that a compaction occurred so the replay pipeline can reconstruct the
/// model's conversation view when rewinding across the compaction boundary.
///
/// The actual compacted conversation is stored in a separate file under
/// `compaction_checkpoints/{checkpoint_id}.json` to keep `updates.jsonl` lean.
CompactionCheckpoint(Box<CompactionCheckpointInfo>),
/// A rewind marker written to `updates.jsonl` when a rewind occurs.
///
/// This is **persist-only** — it is never sent to the gateway/UI. Because
/// `updates.jsonl` is append-only, rewinding creates a timeline branch.
/// The marker tells the replay algorithm to discard accumulated state
/// beyond `target_prompt_index` and continue from that point.
RewindMarker {
/// The prompt index being rewound to (0-based).
target_prompt_index: usize,
/// When the rewind occurred.
created_at: String,
},
/// Task completed notification
TaskCompleted {
task_snapshot: TaskSnapshot,
/// Whether an auto-wake prompt follows this completion. The pager
/// skips its between-turns status line when set — the wake turn's
/// end marker carries the fresh counts instead. Missing (old
/// shells) reads as `false`: emit the line.
#[serde(default)]
will_wake: bool,
},
/// A subagent session has been spawned.
///
/// Sent on the PARENT session's notification channel so the client
/// knows this `child_session_id` is a subagent and can route its events.
/// Emitted BEFORE dispatching `SessionCommand::Prompt` to the child,
/// preventing a race where child events arrive before the client has
/// the session ID mapping.
SubagentSpawned {
/// Unique subagent identifier (same as child session ID).
subagent_id: String,
/// The parent session that spawned this subagent.
parent_session_id: String,
/// The parent prompt/turn that spawned this subagent.
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_prompt_id: Option<String>,
/// The child session's ACP session ID.
child_session_id: String,
/// Agent type used for the subagent ("general-purpose", "explore", "plan", or custom).
subagent_type: String,
/// Short human-readable description of the task.
description: String,
/// Effective context source after bootstrap: "new" or "resumed".
#[serde(default, skip_serializing_if = "Option::is_none")]
effective_context_source: Option<String>,
/// Whether the forked context was normalized into <background_context>.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
context_normalized: bool,
/// Capability mode applied to this subagent (e.g. "read-only").
#[serde(default, skip_serializing_if = "Option::is_none")]
capability_mode: Option<String>,
/// Named persona applied to this subagent.
#[serde(default, skip_serializing_if = "Option::is_none")]
persona: Option<String>,
/// Role that supplied defaults for this subagent (e.g. "researcher").
#[serde(default, skip_serializing_if = "Option::is_none")]
role: Option<String>,
/// Effective model ID used by the subagent (may differ from the parent).
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
/// ID of the source subagent this session was resumed from.
#[serde(default, skip_serializing_if = "Option::is_none")]
resumed_from: Option<String>,
},
/// Periodic progress update for a running subagent.
///
/// Sent on the PARENT session's notification channel at a rate-limited
/// cadence (every ~2s while the subagent is active). Stops automatically
/// when the subagent completes or is cancelled. The TUI merges these
/// into the same state path used by ACP poll responses.
SubagentProgress {
/// Unique subagent identifier.
subagent_id: String,
/// The parent session that owns this subagent.
parent_session_id: String,
/// The child session's ACP session ID.
child_session_id: String,
/// Elapsed wall-clock time in milliseconds.
duration_ms: u64,
/// Number of completed turns so far.
turn_count: u32,
/// Total tool calls executed so far.
tool_call_count: u32,
/// Current tokens used in the context window.
tokens_used: u64,
/// Total context window capacity (tokens).
context_window_tokens: u64,
/// Context window usage as a percentage (0-100).
context_usage_pct: u8,
/// Distinct tool names called so far.
tools_used: Vec<String>,
/// Number of errors encountered so far.
error_count: u32,
},
/// A subagent session has finished (success, failure, or cancellation).
///
/// Sent on the PARENT session's notification channel.
SubagentFinished {
/// Unique subagent identifier.
subagent_id: String,
/// The child session's ACP session ID.
child_session_id: String,
/// Outcome: "completed", "failed", or "cancelled".
status: String,
/// Error message if the subagent failed.
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// Number of tool calls made by the subagent.
tool_calls: u32,
/// Number of conversation turns taken by the subagent.
turns: u32,
/// Total wall-clock duration in milliseconds.
duration_ms: u64,
/// Total tokens consumed by the subagent's context window.
#[serde(default)]
tokens_used: u64,
/// Final output text from the subagent (if completed).
#[serde(default, skip_serializing_if = "Option::is_none")]
output: Option<String>,
/// Whether an auto-wake prompt follows this completion. The pager
/// skips its between-turns status line when set — the wake turn's
/// end marker carries the fresh counts instead. Missing (old
/// shells) reads as `false`: emit the line.
#[serde(default)]
will_wake: bool,
},
/// Task backgrounded notification — a bash command transitioned to background execution.
/// Sent for both direct `is_background=true` tasks and foreground→background transitions.
TaskBackgrounded {
/// The tool_call_id of the bash tool invocation.
tool_call_id: String,
/// The background task registry ID.
task_id: String,
/// The shell command being executed.
command: String,
/// Absolute path of the working directory.
cwd: String,
/// Absolute path to the output log file on disk.
output_file: String,
/// For monitor tasks: the monitor's human-readable description.
/// `None` for ordinary backgrounded bash commands. Lets the pager
/// render monitors with a "Monitor" tag instead of bash-highlighting
/// the command string.
#[serde(default, skip_serializing_if = "Option::is_none")]
monitor_description: Option<String>,
/// Model-supplied tool `description` for ordinary bash bg tasks
/// (e.g. "Wait for the server to start"). Prefer over raw `command`
/// in the pager "Task started" line / tasks pane. `None` when omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
ScheduledTaskCreated {
task_id: String,
prompt: String,
human_schedule: String,
next_fire_at: Option<String>,
},
ScheduledTaskFired {
task_id: String,
prompt: String,
human_schedule: String,
next_fire_at: Option<String>,
},
/// A scheduled task was deleted/cancelled.
ScheduledTaskDeleted { task_id: String },
/// A monitor event (stdout line from a monitor background process).
MonitorEvent {
task_id: String,
description: String,
/// Raw event text (NOT XML-wrapped -- for pager stdout display).
event_text: String,
},
/// The session's model was auto-switched because the persisted model
/// is no longer available for this user.
ModelAutoSwitched {
/// The model ID that was persisted in the session but is no longer available.
previous_model_id: String,
/// The model ID that was selected as a replacement.
new_model_id: String,
/// Human-readable reason for the switch.
reason: String,
},
/// The session's model was switched via `session/setModel`.
///
/// Broadcast to every client subscribed to the session in leader mode so
/// follower clients (TUI / IDE / web) mirror the change in their local
/// state — status bar, `/model` dropdown, prompt header, etc. The
/// originating client also receives this (the leader broadcasts to all
/// subscribers of the session) but skips applying it because its in-flight
/// `SetSessionModel` response is the authority for its local state and
/// drives the single "Switched to X" scrollback entry. Followers gate on
/// their own `model_switch_pending` flag to distinguish "I'm waiting on
/// my own switch" from "someone else's switch arrived."
ModelChanged {
/// The newly-selected model id (catalog key).
model_id: String,
/// Effective reasoning effort, post-resolution. `None` when the model
/// does not support reasoning effort or no effort override was applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
},
/// Streaming chunk of a tool call's arguments.
///
/// Behaves like `acp::SessionUpdate::AgentMessageChunk` /
/// `AgentThoughtChunk`: flows through the replay buffer, gets merged
/// with adjacent chunks for the same `tool_call_id`, and is debounced
/// at the session's buffering interval.
/// Only persisted as a full `acp::SessionUpdate::ToolCall`.
ToolCallDeltaChunk {
/// Stable model-provided id (e.g. `"call_abc"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
/// Positional index assigned within the assistant tool calls.
tool_index: u32,
/// Tool name (e.g. `"search_replace"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
/// Raw JSON-fragment string. NOT valid JSON in isolation.
#[serde(default, skip_serializing_if = "Option::is_none")]
arguments_delta: Option<String>,
},
/// One or more prompt images were resized to fit within API limits.
ImageCompressed {
images: Vec<ImageCompressedEntry>,
/// Human-readable summary for display.
message: String,
},
/// Prompt images dropped before send (integrity / upscale-cap). The
/// model is told via a system-reminder; this surfaces them to the UI.
ImageDropped { notes: Vec<String> },
/// Memory file listing for the pager's /memory modal.
MemoryFiles { files: Vec<MemoryFileInfo> },
/// Goal mode orchestration progress update.
///
/// Sent on the parent session's notification channel at phase transitions
/// and rate-limited from the progress handler (max 1/s). Fire-and-forget
/// to pager — not actionable.
GoalUpdated {
goal_id: String,
objective: String,
/// `"active"`, `"user_paused"`, `"back_off_paused"`,
/// `"no_progress_paused"`, `"infra_paused"`, `"blocked"`,
/// `"budget_limited"`, `"complete"`, `"cleared"`.
/// Legacy `"doom_loop_paused"` is accepted by pagers as user-paused.
status: String,
/// `"idle"`, `"planning"`, `"executing"`
phase: String,
#[serde(skip_serializing_if = "Option::is_none")]
token_budget: Option<i64>,
#[serde(default)]
tokens_used: i64,
elapsed_ms: u64,
total_deliverables: u32,
completed_deliverables: u32,
/// Wire compat: always `None` in the simplified goal model.
/// Retained for cross-version compatibility with older pagers.
#[serde(
rename = "current_deliverable_idx",
skip_serializing_if = "Option::is_none"
)]
current_deliverable_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
current_deliverable_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
current_subagent_role: Option<String>,
total_worker_rounds: u32,
total_verify_rounds: u32,
#[serde(default)]
token_baseline: i64,
#[serde(default)]
finished_subagent_tokens: i64,
#[serde(skip_serializing_if = "Option::is_none")]
live_subagent_tokens: Option<u64>,
/// Per-model marginal-token breakdown `(model_id, tokens)`, sorted
/// by tokens descending. The producer (`build_goal_updated`) only
/// populates this when ≥2 distinct models appear; a single-model
/// goal collapses to the single tokens line, so the field is empty
/// (and omitted on the wire). The pager re-checks ≥2 as defence in
/// depth.
///
/// This is a live, active-subagent-window field (it mirrors
/// `live_subagent_tokens` and is cleared on `SubagentFinished`): the
/// pager renders it only under the "Active subagent" block. The
/// producer must therefore keep its populate gate on that same
/// axis so the wire and render gates stay aligned.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
live_tokens_by_model: Vec<(String, u64)>,
#[serde(skip_serializing_if = "Option::is_none")]
live_context_pct: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
live_turn_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
live_tool_call_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
last_event: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_event_detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_event_timestamp: Option<String>,
/// Wire compat: always empty in the simplified goal model.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
deliverables: Vec<GoalDeliverableInfo>,
/// Human-readable explanation set when the goal entered a paused
/// state with a meaningful reason (today only `"blocked"`).
/// Rendered by the pager under the status row in the goal modal.
/// Invariant: `Some` iff `status` is a paused-variant string AND
/// the underlying pause was created via the message-carrying
/// path. The shell clears this on every transition out of a
/// paused state (resume / complete / budget_limit); the pager
/// also gates rendering on `is_paused()` as a defence in depth.
#[serde(default, skip_serializing_if = "Option::is_none")]
pause_message: Option<String>,
/// Number of times the goal-achievement classifier has run for
/// this goal. `None` when no classifier run has occurred yet
/// (matches the `total_worker_rounds`-style convention of
/// suppressing the field when the counter is zero so old pagers
/// don't see a stray zero).
#[serde(default, skip_serializing_if = "Option::is_none")]
classifier_runs_attempted: Option<u32>,
/// Hard cap on classifier runs for this goal. `None` when not
/// configured.
#[serde(default, skip_serializing_if = "Option::is_none")]
classifier_max_runs: Option<u32>,
/// Last aggregate verdict returned by the verification stage, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
last_classifier_verdict: Option<GoalClassifierVerdict>,
/// Filesystem path to the most recent verification-stage details artifact.
#[serde(default, skip_serializing_if = "Option::is_none")]
last_classifier_details_path: Option<String>,
/// `Some(true)` while a classifier run is in flight. Set only by
/// the dedicated "verifying" notification path — `build_goal_updated`
/// always emits `None` because this flag is not persisted state.
#[serde(default, skip_serializing_if = "Option::is_none")]
verifying_completion: Option<bool>,
/// `Some(true)` while the goal planner subagent is running. Set
/// only by the dedicated "planning" notification path —
/// `build_goal_updated` always emits `None` because this flag is
/// not persisted state.
#[serde(default, skip_serializing_if = "Option::is_none")]
planning: Option<bool>,
},
/// A blocking reverse-request (permission / `ask_user_question` /
/// plan-approval) is now **pending** on the agent, keyed by `tool_call_id`
/// Fire-and-forget, **never persisted** — it is a request,
/// not a notification. Subscribers show ⏳ NeedsInput for this session.
PendingInteraction {
tool_call_id: String,
kind: crate::session::pending_interaction::PendingKind,
},
/// A previously-pending reverse-request **resolved** (answered, cancelled,
/// or errored). Fire-and-forget, **never persisted**. Subscribers clear the
/// pending ⏳ for this `tool_call_id`.
InteractionResolved { tool_call_id: String },
/// The durable, replayable signal that a turn reached its terminal
/// outcome. Rides the persisted `_kigi/session/update` rail (unlike the
/// fire-and-forget `kigi/session/prompt_complete` notification), so a
/// viewer that re-attaches mid-turn can finalize the turn from replay
/// instead of staying stuck on "Waiting…".
TurnCompleted {
/// Correlation key the re-attaching viewer finalizes the turn on:
/// the prompt/turn whose terminal outcome this carries.
prompt_id: String,
/// Why the turn ended (the model's stop reason, or e.g. "cancelled").
stop_reason: String,
/// Final agent result text, when the turn produced one.
#[serde(default, skip_serializing_if = "Option::is_none")]
agent_result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
usage: Option<PromptUsage>,
},
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
current_node_title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
token_budget: Option<i64>,
#[serde(default)]
tokens_spent: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_event: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pause_message: Option<String>,
},
/// 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.
#[serde(other)]
Unknown,
}
/// Metadata for a single memory file, sent to the pager for the memory modal.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct MemoryFileInfo {
pub path: String,
/// `"global"`, `"workspace"`, or `"session"`.
pub source: String,
pub size_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified_epoch_secs: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct ImageCompressedEntry {
pub index: usize,
pub original_bytes: usize,
pub compressed_bytes: usize,
pub original_width: u32,
pub original_height: u32,
pub compressed_width: u32,
pub compressed_height: u32,
}
impl From<&crate::session::image_normalize::ImageCompressionInfo> for ImageCompressedEntry {
fn from(c: &crate::session::image_normalize::ImageCompressionInfo) -> Self {
Self {
index: c.index,
original_bytes: c.original_bytes,
compressed_bytes: c.compressed_bytes,
original_width: c.original_width,
original_height: c.original_height,
compressed_width: c.compressed_width,
compressed_height: c.compressed_height,
}
}
}
/// State of a retry operation or error for visual feedback in the TUI
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum RetryState {
/// A retry is in progress
Retrying {
/// Current retry attempt number (1-indexed)
attempt: u32,
/// Maximum number of retries allowed
max_retries: u32,
/// Human-readable reason for the retry
reason: String,
},
/// All retries have been exhausted
Exhausted {
/// Total number of attempts made
attempts: u32,
/// Human-readable reason for the failure
reason: String,
/// True when the exhaustion was caused by an HTTP 429 rate limit.
/// Clients use this to show a user-friendly upgrade message instead
/// of the raw `reason` string.
#[serde(default)]
is_rate_limited: bool,
},
/// A non-retryable error occurred (e.g., auth error, invalid params)
Failed {
/// Category of the error (e.g., "auth", "invalid_params", "server")
error_type: String,
/// Human-readable error message
message: String,
},
}
/// Whether a terminal retry failure is a recoverable authentication error
/// (expired/invalid credentials, 401) that the user can fix by signing in
/// again. Drives the actionable re-auth banner.
///
/// `legacy_auth` is intentionally excluded: those failures carry their own
/// detailed migration guidance (`kigi logout` / `kigi login`) in the
/// message, so we surface that verbatim instead of the generic prompt.
pub fn is_reauthable_failure(error_type: Option<&str>, message: &str) -> bool {
if error_type == Some("legacy_auth") {
return false;
}
error_type == Some("auth") || message.contains("Unauthorized (401)")
}
/// A diff content item that serializes compatibly with `acp::ToolCallContent::Diff`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(tag = "type", rename = "diff")]
pub struct DiffContent {
/// The diff details.
#[serde(flatten)]
pub diff: acp::Diff,
}
/// Notification requesting user feedback based on session heuristics.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct FeedbackRequestNotification {
/// Unique ID for this feedback request
pub request_id: String,
/// The tier that triggered this request
pub tier: String,
/// Human-readable prompt to show the user
pub prompt: String,
/// Whether this is a non-intrusive/dismissible request
pub dismissible: bool,
/// Trigger type identifier (e.g., "tier1_engagement", "tier2_complex_recovery")
pub trigger_type: String,
/// The specific condition that was met (e.g., "turns >= 10 AND tool_calls >= 5 AND ...")
pub trigger_condition: String,
/// Human-readable explanation of what triggered this request with actual values
pub trigger_reason: String,
pub stars: bool,
pub thumbs: bool,
pub text: bool,
}
impl From<FeedbackRequestData> for FeedbackRequestNotification {
fn from(data: FeedbackRequestData) -> Self {
Self {
request_id: data.request_id,
tier: format!("{:?}", data.tier).to_lowercase(),
stars: data.stars,
thumbs: data.thumbs,
text: data.text,
prompt: data.prompt,
dismissible: data.dismissible,
trigger_type: data.trigger_type,
trigger_condition: data.trigger_condition.condition.clone(),
trigger_reason: data.trigger_condition.trigger_reason(),
}
}
}
// ── Compaction checkpoint types ────────────────────────────────────────
/// Metadata stored in `updates.jsonl` as a `CompactionCheckpoint` session update.
///
/// This is a lightweight reference; the full compacted conversation lives in a
/// separate file (`compaction_checkpoints/{checkpoint_id}.json`).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct CompactionCheckpointInfo {
/// Unique checkpoint identifier (UUID).
pub checkpoint_id: String,
/// The prompt index at the time compaction completed.
/// The next real user prompt will receive this index.
pub prompt_index_at_compaction: usize,
/// Relative path to the checkpoint file inside the session directory
/// (e.g., `"compaction_checkpoints/<uuid>.json"`).
pub checkpoint_file: String,
/// If this compaction was triggered by auto-compact, contains the
/// auto-continue prompt that was injected after compaction.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_continue: Option<AutoContinueInfo>,
/// Schema version for forward compatibility.
pub schema_version: u32,
/// ISO 8601 timestamp of when the checkpoint was created.
pub created_at: String,
}
/// Information about the auto-continue prompt injected after auto-compaction.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct AutoContinueInfo {
/// The exact text of the auto-continue prompt that was added as a user message.
pub prompt_text: String,
}
/// The on-disk format for a compaction checkpoint file.
///
/// Stored at `{session_dir}/compaction_checkpoints/{checkpoint_id}.json`.
/// Contains the full compacted conversation history so the replay pipeline can
/// deterministically reconstruct the model's view without re-running compaction.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CompactionCheckpointFile {
/// Unique checkpoint identifier (matches [`CompactionCheckpointInfo::checkpoint_id`]).
pub checkpoint_id: String,
/// The prompt index at the time compaction completed.
pub prompt_index_at_compaction: usize,
/// The exact compacted conversation used by the model.
pub compacted_history: Vec<crate::sampling::ConversationItem>,
/// Schema version for forward compatibility.
pub schema_version: u32,
/// ISO 8601 timestamp of when the checkpoint was created.
pub created_at: String,
/// The original User(user_info) text from before compaction.
/// Used during cross-compaction rewind to restore the correct user_info
/// that the model originally saw for pre-compaction turns, rather than
/// the rebuilt user_info from the compacted conversation.
/// `None` in older checkpoints (schema_version 1 without this field).
#[serde(default)]
pub original_user_info: Option<String>,
/// File paths that were re-read and injected after compaction.
/// Informational — for debugging and replay understanding.
#[serde(default)]
pub reread_file_paths: Vec<String>,
}
/// A compaction segment to persist under `compaction/segment_NNN.md`. Storage
/// assigns the resume-safe index and renders the markdown (it owns the index
/// the header/metadata embed), so the caller supplies the render inputs.
#[derive(Debug, Clone)]
pub struct CompactionSegmentFile {
pub items: Vec<kigi_sampling_types::ConversationItem>,
/// Curated summary, analysis tags already stripped.
pub summary: String,
pub detail: kigi_chat_state::CompactionDetail,
/// ISO-8601, for the segment metadata.
pub timestamp: String,
}
/// On-disk artifact capturing the exact compaction request sent to the model
/// plus the response (or final error) it produced.
///
/// Stored at `{session_dir}/compaction_requests/{request_id}.json`. Rides on
/// the post-turn session archive to cloud storage, where it can be downloaded for prompt
/// iteration — you get the exact `chat_history` that was sent, the prompt
/// variant, any `/compact <text>` user context, the model used, and the
/// resulting summary (or error). Replay the request locally to A/B test
/// alternate prompt wordings against the same input.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CompactionRequestFile {
/// Schema version for forward compatibility.
pub schema_version: u32,
/// Unique artifact identifier (filename stem).
/// Note: this is a per-artifact ID, not the model API's `x_kigi_req_id`
/// (which is generated per-attempt inside the sampling layer).
pub request_id: String,
/// ISO 8601 timestamp of when the compaction call started.
pub created_at: String,
/// What kicked off the compaction: `"manual"` (user ran `/compact`) or `"auto"`.
pub trigger: String,
/// Which prompt template was used: `"short"` (concise self-summarization)
/// or `"detailed"` (10-section structured prompt for kigi and similar agents).
pub prompt_variant: String,
/// The model id that ran the summarization.
pub model: String,
/// User-provided context from `/compact <text>`, if any.
pub user_context: Option<String>,
/// The full `ConversationItem` list sent to the model, with the
/// summarization prompt already appended as the final user message.
/// Replaying this against any model reproduces the exact request.
pub chat_history: Vec<crate::sampling::ConversationItem>,
/// Tool definitions attached to the request (same effective set as the
/// turn loop, for prompt-prefix/KV-cache alignment). Empty for artifacts
/// written before tools were attached. Backend-hosted tools are not
/// recorded (not serializable; IC-side concept).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<crate::sampling::ToolSpec>,
/// Generated summary text, on success. `None` if all retries failed.
pub summary: Option<String>,
/// Most recent error message captured during the retry loop, if any.
///
/// `None` on a first-attempt success. May co-occur with `summary` when a
/// transient failure was eventually retried successfully — the field then
/// documents the recovered flake and `summary` carries the final result.
/// On total failure (all retries exhausted, or a deterministic error)
/// `summary` is `None` and this field carries the final error.
pub error: Option<String>,
/// Number of attempts the retry loop made before settling on the final outcome.
pub attempts: u32,
/// Per-attempt diagnostics (one per retry-loop iteration), in order —
/// records each rejected/degraded attempt so retries aren't bumped
/// invisibly. Empty on artifacts written before schema v2.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attempt_details: Vec<kigi_chat_state::compaction_utils::CompactionAttempt>,
}
/// On-disk artifact capturing the exact recap request sent to the model plus
/// the response (or final error) it produced.
///
/// Stored at `{session_dir}/recap_requests/{request_id}.json`. Rides on
/// the post-turn session archive to cloud storage (same path as compaction request
/// artifacts) so recap prompt / model garble can be replayed offline.
/// Recap never mutates the conversation; this file is the only durable
/// record of what was sent for `/recap` or auto return-from-away recap.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct RecapRequestFile {
/// Schema version for forward compatibility.
pub schema_version: u32,
/// Unique artifact identifier (filename stem). Distinct from the model
/// API's `x_kigi_req_id` (also recorded below for proxy correlation).
pub request_id: String,
/// ISO 8601 timestamp of when the recap model call started.
pub created_at: String,
/// What kicked off the recap: `"manual"` (`/recap`) or `"auto"`
/// (return-from-away).
pub trigger: String,
/// The model id used for the recap side-call.
pub model: String,
/// Sampling request id sent to the proxy (`xai-recap-{uuid}`).
pub x_kigi_req_id: String,
/// Sampling conversation id (`recap-{uuid}`).
pub x_kigi_conv_id: String,
/// Whether reasoning/thinking blocks were stripped from the prefix
/// (Anthropic Messages backend only; other backends keep reasoning
/// verbatim for prompt-cache warmth).
pub strip_reasoning: bool,
/// Reminder tag used in the recap instruction (`system-reminder` or
/// the alternate `system_reminder` form).
pub reminder_tag: String,
/// The full `ConversationItem` list sent to the model, with the recap
/// instruction already appended as the final user message. Replaying
/// this against any model reproduces the exact request.
pub chat_history: Vec<crate::sampling::ConversationItem>,
/// Cleaned one-line recap body shown to the user, on success.
/// `None` if the model call failed or returned empty after cleaning.
pub summary: Option<String>,
/// Raw `assistant_text()` from the model before `clean_recap_text`.
/// Useful for diagnosing garble (tool-call XML / CJK junk) that cleaning
/// only partially trims. `None` when the call never returned a response.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_response: Option<String>,
/// Error message if preparation or the model call failed, or if the
/// cleaned summary was empty.
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recap_request_file_roundtrips() {
let artifact = RecapRequestFile {
schema_version: 1,
request_id: "artifact-1".into(),
created_at: "2026-06-30T00:00:00Z".into(),
trigger: "auto".into(),
model: "v9-zingster".into(),
x_kigi_req_id: "xai-recap-abc".into(),
x_kigi_conv_id: "recap-abc".into(),
strip_reasoning: false,
reminder_tag: "system-reminder".into(),
chat_history: vec![],
summary: Some("We fixed the flaky test in queue_worker.".into()),
raw_response: Some("We fixed the flaky test in queue_worker.".into()),
error: None,
};
let json = serde_json::to_string(&artifact).unwrap();
let parsed: RecapRequestFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.schema_version, 1);
assert_eq!(parsed.trigger, "auto");
assert_eq!(parsed.x_kigi_req_id, "xai-recap-abc");
assert_eq!(
parsed.summary.as_deref(),
Some("We fixed the flaky test in queue_worker.")
);
assert!(parsed.error.is_none());
}
#[test]
fn compaction_request_file_v2_roundtrips_attempt_details() {
use kigi_chat_state::compaction_utils::CompactionAttempt;
let artifact = CompactionRequestFile {
schema_version: 2,
request_id: "req-1".into(),
created_at: "2026-06-15T00:00:00Z".into(),
trigger: "auto".into(),
prompt_variant: "detailed".into(),
model: "kigi".into(),
user_context: None,
chat_history: vec![],
tools: vec![],
summary: Some("the accepted summary".into()),
error: None,
attempts: 2,
attempt_details: vec![
// A rejected degraded attempt: the loop captures the raw text.
CompactionAttempt {
attempt: 1,
outcome: "degenerate".into(),
summary_chars: 22,
summary: Some("Now I will do X, Y, Z.".into()),
error: None,
},
// The accepted retry: text lives in the top-level `summary`.
CompactionAttempt {
attempt: 2,
outcome: "success".into(),
summary_chars: 4096,
summary: None,
error: None,
},
],
};
let json = serde_json::to_string(&artifact).unwrap();
let parsed: CompactionRequestFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.schema_version, 2);
assert_eq!(parsed.attempt_details.len(), 2);
assert_eq!(parsed.attempt_details[0].outcome, "degenerate");
assert_eq!(
parsed.attempt_details[0].summary.as_deref(),
Some("Now I will do X, Y, Z.")
);
assert_eq!(parsed.attempt_details[1].outcome, "success");
assert_eq!(parsed.attempt_details[1].summary, None);
}
#[test]
fn compaction_request_file_v1_without_attempt_details_defaults_empty() {
// A pre-schema-v2 artifact carries no `attempt_details` key — it must
// still deserialize, defaulting the new field to empty.
let json = serde_json::json!({
"schema_version": 1,
"request_id": "req-old",
"created_at": "2026-06-01T00:00:00Z",
"trigger": "manual",
"prompt_variant": "detailed",
"model": "kigi",
"user_context": null,
"chat_history": [],
"summary": "ok",
"error": null,
"attempts": 1
});
let parsed: CompactionRequestFile = serde_json::from_value(json).unwrap();
assert!(parsed.attempt_details.is_empty());
assert!(parsed.tools.is_empty());
}
#[test]
fn subagent_progress_serializes_snake_case_tag() {
let update = SessionUpdate::SubagentProgress {
subagent_id: "sub-1".into(),
parent_session_id: "parent-1".into(),
child_session_id: "child-1".into(),
duration_ms: 5000,
turn_count: 3,
tool_call_count: 12,
tokens_used: 45_000,
context_window_tokens: 256_000,
context_usage_pct: 35,
tools_used: vec!["bash".into(), "grep".into()],
error_count: 1,
};
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["sessionUpdate"], "subagent_progress");
// Fields serialize as snake_case (Rust field names) — enum
// `rename_all` only applies to the tag, not struct fields.
assert_eq!(json["subagent_id"], "sub-1");
assert_eq!(json["parent_session_id"], "parent-1");
assert_eq!(json["child_session_id"], "child-1");
assert_eq!(json["duration_ms"], 5000);
assert_eq!(json["turn_count"], 3);
assert_eq!(json["tool_call_count"], 12);
assert_eq!(json["tokens_used"], 45_000);
assert_eq!(json["context_window_tokens"], 256_000);
assert_eq!(json["context_usage_pct"], 35);
assert_eq!(json["tools_used"], serde_json::json!(["bash", "grep"]));
assert_eq!(json["error_count"], 1);
}
#[test]
fn subagent_progress_roundtrips_through_json() {
let update = SessionUpdate::SubagentProgress {
subagent_id: "sub-rt".into(),
parent_session_id: "p".into(),
child_session_id: "c".into(),
duration_ms: 100,
turn_count: 1,
tool_call_count: 2,
tokens_used: 1000,
context_window_tokens: 256_000,
context_usage_pct: 1,
tools_used: vec![],
error_count: 0,
};
let json_str = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, parsed);
}
#[test]
fn subagent_progress_orders_after_spawned_before_finished() {
// Verify that SubagentProgress appears between SubagentSpawned
// and SubagentFinished in the enum definition (important for
// notification ordering expectations).
let spawned = serde_json::to_value(SessionUpdate::SubagentSpawned {
subagent_id: "s".into(),
parent_session_id: "p".into(),
parent_prompt_id: None,
child_session_id: "c".into(),
subagent_type: "explore".into(),
description: "d".into(),
effective_context_source: None,
context_normalized: false,
capability_mode: None,
persona: None,
role: None,
model: None,
resumed_from: None,
})
.unwrap();
let progress = serde_json::to_value(SessionUpdate::SubagentProgress {
subagent_id: "s".into(),
parent_session_id: "p".into(),
child_session_id: "c".into(),
duration_ms: 100,
turn_count: 1,
tool_call_count: 1,
tokens_used: 100,
context_window_tokens: 256_000,
context_usage_pct: 0,
tools_used: vec![],
error_count: 0,
})
.unwrap();
let finished = serde_json::to_value(SessionUpdate::SubagentFinished {
subagent_id: "s".into(),
child_session_id: "c".into(),
status: "completed".into(),
error: None,
tool_calls: 1,
turns: 1,
duration_ms: 200,
tokens_used: 50_000,
output: None,
will_wake: false,
})
.unwrap();
// All three should have distinct tags
assert_eq!(spawned["sessionUpdate"], "subagent_spawned");
assert_eq!(progress["sessionUpdate"], "subagent_progress");
assert_eq!(finished["sessionUpdate"], "subagent_finished");
}
#[test]
fn subagent_finished_without_tokens_used_backward_compat() {
// Old JSONL entries written before the tokens_used field was added
// must deserialize successfully with tokens_used defaulting to 0.
// modelUsage on older wire is ignored (billing is RecordSubagentUsage only).
let json = r#"{
"sessionUpdate": "subagent_finished",
"subagent_id": "sa-old",
"child_session_id": "cs-old",
"status": "completed",
"tool_calls": 3,
"turns": 1,
"duration_ms": 5000,
"modelUsage": { "m": { "inputTokens": 1 } }
}"#;
let update: SessionUpdate = serde_json::from_str(json).unwrap();
match update {
SessionUpdate::SubagentFinished {
subagent_id,
tokens_used,
output,
..
} => {
assert_eq!(subagent_id, "sa-old");
assert_eq!(tokens_used, 0, "missing field must default to 0");
assert_eq!(output, None);
}
other => panic!("expected SubagentFinished, got {other:?}"),
}
}
#[test]
fn subagent_finished_with_tokens_used_roundtrips() {
let update = SessionUpdate::SubagentFinished {
subagent_id: "sa-rt".into(),
child_session_id: "cs-rt".into(),
status: "completed".into(),
error: None,
tool_calls: 5,
turns: 2,
duration_ms: 10_000,
tokens_used: 75_000,
output: Some("done".into()),
will_wake: false,
};
let json_str = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, parsed);
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["tokens_used"], 75_000);
}
#[test]
fn unknown_variant_deserializes_from_removed_git_branch_update() {
let json = r#"{"sessionUpdate": "git_branch_update", "branch": "main"}"#;
let update: SessionUpdate = serde_json::from_str(json).unwrap();
assert_eq!(update, SessionUpdate::Unknown);
}
#[test]
fn unknown_variant_deserializes_from_arbitrary_future_variant() {
let json = r#"{"sessionUpdate": "some_future_feature", "data": 42}"#;
let update: SessionUpdate = serde_json::from_str(json).unwrap();
assert_eq!(update, SessionUpdate::Unknown);
}
#[test]
fn unknown_variant_in_session_notification_envelope() {
let json = r#"{
"sessionId": "sess-123",
"update": {"sessionUpdate": "git_branch_update", "branch": "main"}
}"#;
let notification: SessionNotification = serde_json::from_str(json).unwrap();
assert_eq!(notification.session_id.0.as_ref(), "sess-123");
assert_eq!(notification.update, SessionUpdate::Unknown);
}
#[test]
fn known_variants_still_deserialize_correctly() {
// MemoryFlushStarted (unit variant)
let json = r#"{"sessionUpdate": "memory_flush_started"}"#;
let update: SessionUpdate = serde_json::from_str(json).unwrap();
assert_eq!(update, SessionUpdate::MemoryFlushStarted);
// AutoCompactFailed (struct variant)
let json = r#"{"sessionUpdate": "auto_compact_failed", "error": "oom"}"#;
let update: SessionUpdate = serde_json::from_str(json).unwrap();
assert_eq!(
update,
SessionUpdate::AutoCompactFailed {
error: "oom".into()
}
);
// RetryState (newtype variant)
let json = r#"{"sessionUpdate": "retry_state", "type": "failed", "error_type": "auth", "message": "bad token"}"#;
let update: SessionUpdate = serde_json::from_str(json).unwrap();
assert!(matches!(
update,
SessionUpdate::RetryState(RetryState::Failed { .. })
));
}
#[test]
fn memory_flush_completed_with_path_roundtrips() {
let update = SessionUpdate::MemoryFlushCompleted {
result: "written".into(),
path: Some("/home/user/.kigi/memory/ws/sessions/log.md".into()),
};
let json_str = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, parsed);
}
#[test]
fn memory_flush_completed_without_path_backward_compat() {
// Old format without path field should deserialize with path=None
let json = r#"{"sessionUpdate": "memory_flush_completed", "result": "written"}"#;
let update: SessionUpdate = serde_json::from_str(json).unwrap();
assert_eq!(
update,
SessionUpdate::MemoryFlushCompleted {
result: "written".into(),
path: None,
}
);
}
#[test]
fn memory_dream_completed_roundtrips() {
let update = SessionUpdate::MemoryDreamCompleted {
result: "written (500 chars)".into(),
path: Some("/home/user/.kigi/memory/ws/MEMORY.md".into()),
};
let json_str = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, parsed);
}
#[test]
fn memory_session_saved_roundtrips() {
let update = SessionUpdate::MemorySessionSaved {
path: "/home/user/.kigi/memory/ws/sessions/2026-01-15-fix-auth-abc12345.md".into(),
};
let json_str = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, parsed);
}
#[test]
fn unknown_serializes_with_stable_tag() {
let json = serde_json::to_value(&SessionUpdate::Unknown).unwrap();
assert_eq!(json["sessionUpdate"], "unknown");
}
#[test]
fn unknown_roundtrips_through_json() {
let json_str = serde_json::to_string(&SessionUpdate::Unknown).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed, SessionUpdate::Unknown);
}
#[test]
fn memory_files_variant_roundtrips_through_json() {
let update = SessionUpdate::MemoryFiles {
files: vec![
MemoryFileInfo {
path: "/home/user/.kigi/memory/MEMORY.md".into(),
source: "global".into(),
size_bytes: 1024,
modified_epoch_secs: Some(1_700_000_000),
},
MemoryFileInfo {
path: "/project/.kigi/memory/MEMORY.md".into(),
source: "workspace".into(),
size_bytes: 512,
modified_epoch_secs: None,
},
],
};
let json_str = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, parsed);
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["sessionUpdate"], "memory_files");
assert_eq!(json["files"].as_array().unwrap().len(), 2);
// Populated timestamp serializes as a plain u64
assert_eq!(json["files"][0]["modified_epoch_secs"], 1_700_000_000_u64);
// None is omitted entirely
assert!(json["files"][1].get("modified_epoch_secs").is_none());
}
#[test]
fn memory_files_empty_list_serializes() {
let update = SessionUpdate::MemoryFiles { files: vec![] };
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["sessionUpdate"], "memory_files");
assert!(json["files"].as_array().unwrap().is_empty());
let json_str = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, parsed);
}
#[test]
fn tool_call_delta_chunk_first_event_serializes_with_id_and_name() {
// First chunk for a tool: carries id+name, no arguments_delta.
let update = SessionUpdate::ToolCallDeltaChunk {
tool_call_id: Some("call_abc".into()),
tool_index: 0,
name: Some("search_replace".into()),
arguments_delta: None,
};
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["sessionUpdate"], "tool_call_delta_chunk");
assert_eq!(json["tool_call_id"], "call_abc");
assert_eq!(json["tool_index"], 0);
assert_eq!(json["name"], "search_replace");
// None fields are skipped (cleaner wire payload, fewer bytes).
assert!(json.get("arguments_delta").is_none());
}
#[test]
fn tool_call_delta_chunk_subsequent_event_carries_only_arguments_delta() {
// Later chunks omit id+name; only the JSON fragment travels.
let update = SessionUpdate::ToolCallDeltaChunk {
tool_call_id: None,
tool_index: 0,
name: None,
arguments_delta: Some("{\"file\":\"src/".into()),
};
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["sessionUpdate"], "tool_call_delta_chunk");
assert_eq!(json["tool_index"], 0);
assert_eq!(json["arguments_delta"], "{\"file\":\"src/");
// Optional fields skipped when None.
assert!(json.get("tool_call_id").is_none());
assert!(json.get("name").is_none());
}
#[test]
fn tool_call_delta_chunk_roundtrips_through_json() {
let cases = vec![
SessionUpdate::ToolCallDeltaChunk {
tool_call_id: Some("call_1".into()),
tool_index: 0,
name: Some("Bash".into()),
arguments_delta: None,
},
SessionUpdate::ToolCallDeltaChunk {
tool_call_id: None,
tool_index: 0,
name: None,
arguments_delta: Some("{\"command\":\"ls\"}".into()),
},
SessionUpdate::ToolCallDeltaChunk {
tool_call_id: Some("call_2".into()),
tool_index: 1,
name: Some("ReadFile".into()),
arguments_delta: Some("{\"path\":".into()),
},
];
for update in cases {
let s = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&s).unwrap();
assert_eq!(update, parsed, "round-trip mismatch for {s}");
}
}
#[test]
fn tool_call_delta_chunk_unknown_extra_fields_dont_break_deserialization() {
// Forward-compat: if a future shell adds new optional fields,
// older deserializers must still accept the message.
let json = r#"{
"sessionUpdate": "tool_call_delta_chunk",
"tool_call_id": "call_x",
"tool_index": 7,
"name": "future_tool",
"arguments_delta": "...",
"future_field": "ignored"
}"#;
let update: SessionUpdate = serde_json::from_str(json).expect("parses with extra field");
match update {
SessionUpdate::ToolCallDeltaChunk {
tool_call_id,
tool_index,
name,
arguments_delta,
} => {
assert_eq!(tool_call_id.as_deref(), Some("call_x"));
assert_eq!(tool_index, 7);
assert_eq!(name.as_deref(), Some("future_tool"));
assert_eq!(arguments_delta.as_deref(), Some("..."));
}
other => panic!("expected ToolCallDeltaChunk, got {other:?}"),
}
}
/// Helper: `GoalUpdated` with all optional fields populated.
fn make_goal_updated_full() -> SessionUpdate {
SessionUpdate::GoalUpdated {
goal_id: "g-1".into(),
objective: "Build widget".into(),
status: "active".into(),
phase: "executing".into(),
token_budget: Some(100_000),
tokens_used: 25_000,
elapsed_ms: 5000,
total_deliverables: 3,
completed_deliverables: 1,
current_deliverable_id: Some(2),
current_deliverable_title: Some("Core logic".into()),
current_subagent_role: Some("worker".into()),
total_worker_rounds: 4,
total_verify_rounds: 2,
live_subagent_tokens: Some(10_000),
live_tokens_by_model: vec![("kigi-4".into(), 6_000), ("kigi-3".into(), 4_000)],
live_context_pct: Some(35),
live_turn_count: Some(3),
live_tool_call_count: Some(8),
last_event: Some("worker_completed".into()),
last_event_detail: Some("Core logic".into()),
last_event_timestamp: Some("2026-01-01T00:05:00Z".into()),
token_baseline: 0,
finished_subagent_tokens: 0,
deliverables: vec![],
pause_message: None,
classifier_runs_attempted: Some(2),
classifier_max_runs: Some(3),
last_classifier_verdict: Some(GoalClassifierVerdict::NotAchieved),
last_classifier_details_path: Some("/tmp/details.md".into()),
verifying_completion: Some(true),
planning: Some(true),
}
}
/// Helper: `GoalUpdated` with all optional fields `None` / zeroed.
fn make_goal_updated_minimal() -> SessionUpdate {
SessionUpdate::GoalUpdated {
goal_id: "g-min".into(),
objective: "Test".into(),
status: "active".into(),
phase: "idle".into(),
token_budget: None,
tokens_used: 0,
elapsed_ms: 0,
total_deliverables: 0,
completed_deliverables: 0,
current_deliverable_id: None,
current_deliverable_title: None,
current_subagent_role: None,
total_worker_rounds: 0,
total_verify_rounds: 0,
live_subagent_tokens: None,
live_tokens_by_model: Vec::new(),
live_context_pct: None,
live_turn_count: None,
live_tool_call_count: None,
last_event: None,
last_event_detail: None,
last_event_timestamp: None,
token_baseline: 0,
finished_subagent_tokens: 0,
deliverables: vec![],
pause_message: None,
classifier_runs_attempted: None,
classifier_max_runs: None,
last_classifier_verdict: None,
last_classifier_details_path: None,
verifying_completion: None,
planning: None,
}
}
#[test]
fn goal_updated_serializes_snake_case_tag() {
let update = make_goal_updated_full();
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["sessionUpdate"], "goal_updated");
assert_eq!(json["goal_id"], "g-1");
assert_eq!(json["status"], "active");
assert_eq!(json["phase"], "executing");
assert_eq!(json["token_budget"], 100_000);
assert_eq!(json["tokens_used"], 25_000);
assert_eq!(json["total_deliverables"], 3);
assert_eq!(json["completed_deliverables"], 1);
assert_eq!(json["current_deliverable_idx"], 2);
assert_eq!(json["total_worker_rounds"], 4);
assert_eq!(json["total_verify_rounds"], 2);
assert_eq!(json["live_subagent_tokens"], 10_000);
assert_eq!(json["live_tokens_by_model"][0][0], "kigi-4");
assert_eq!(json["live_tokens_by_model"][0][1], 6_000);
assert_eq!(json["live_context_pct"], 35);
assert_eq!(json["last_event"], "worker_completed");
assert_eq!(json["classifier_runs_attempted"], 2);
assert_eq!(json["classifier_max_runs"], 3);
assert_eq!(json["last_classifier_verdict"], "not_achieved");
assert_eq!(json["last_classifier_details_path"], "/tmp/details.md");
assert_eq!(json["verifying_completion"], true);
assert_eq!(json["planning"], true);
}
#[test]
fn goal_updated_roundtrips_through_json() {
let update = make_goal_updated_minimal();
let json_str = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, parsed);
// Also roundtrip the full variant.
let full = make_goal_updated_full();
let json_str = serde_json::to_string(&full).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(full, parsed);
}
#[test]
fn goal_updated_optional_fields_skipped_when_none() {
let json = serde_json::to_value(make_goal_updated_minimal()).unwrap();
assert!(json.get("token_budget").is_none());
assert!(json.get("current_deliverable_idx").is_none());
assert!(json.get("current_deliverable_title").is_none());
assert!(json.get("current_subagent_role").is_none());
assert!(json.get("live_subagent_tokens").is_none());
assert!(json.get("live_tokens_by_model").is_none());
assert!(json.get("live_context_pct").is_none());
assert!(json.get("live_turn_count").is_none());
assert!(json.get("live_tool_call_count").is_none());
assert!(json.get("last_event").is_none());
assert!(json.get("last_event_detail").is_none());
assert!(json.get("last_event_timestamp").is_none());
assert!(json.get("classifier_runs_attempted").is_none());
assert!(json.get("classifier_max_runs").is_none());
assert!(json.get("last_classifier_verdict").is_none());
assert!(json.get("last_classifier_details_path").is_none());
assert!(json.get("verifying_completion").is_none());
assert!(json.get("planning").is_none());
}
#[test]
fn goal_updated_payload_missing_new_fields_deserializes_to_none() {
// Wire round-trip: an older shell that predates the
// classifier / planning fields will omit them all. Each must
// deserialize to `None` so the pager keeps working.
let json = r#"{
"sessionUpdate": "goal_updated",
"goal_id": "g-old",
"objective": "test",
"status": "active",
"phase": "idle",
"tokens_used": 0,
"elapsed_ms": 0,
"total_deliverables": 0,
"completed_deliverables": 0,
"total_worker_rounds": 0,
"total_verify_rounds": 0
}"#;
let update: SessionUpdate = serde_json::from_str(json).unwrap();
match update {
SessionUpdate::GoalUpdated {
classifier_runs_attempted,
classifier_max_runs,
last_classifier_verdict,
last_classifier_details_path,
verifying_completion,
planning,
..
} => {
assert_eq!(classifier_runs_attempted, None);
assert_eq!(classifier_max_runs, None);
assert_eq!(last_classifier_verdict, None);
assert_eq!(last_classifier_details_path, None);
assert_eq!(verifying_completion, None);
assert_eq!(planning, None);
}
other => panic!("expected GoalUpdated, got {other:?}"),
}
}
#[test]
fn goal_updated_payload_with_new_fields_round_trips() {
// Wire round-trip: a payload that carries every classifier
// field round-trips through serialize → deserialize without
// mutation.
let update = make_goal_updated_full();
let json_str = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, parsed);
match parsed {
SessionUpdate::GoalUpdated {
classifier_runs_attempted,
classifier_max_runs,
last_classifier_verdict,
last_classifier_details_path,
verifying_completion,
planning,
..
} => {
assert_eq!(classifier_runs_attempted, Some(2));
assert_eq!(classifier_max_runs, Some(3));
assert_eq!(
last_classifier_verdict,
Some(GoalClassifierVerdict::NotAchieved)
);
assert_eq!(
last_classifier_details_path.as_deref(),
Some("/tmp/details.md")
);
assert_eq!(verifying_completion, Some(true));
assert_eq!(planning, Some(true));
}
other => panic!("expected GoalUpdated, got {other:?}"),
}
}
#[test]
fn goal_updated_backward_compat_without_optional_fields() {
let json = r#"{
"sessionUpdate": "goal_updated",
"goal_id": "g-bc",
"objective": "test",
"status": "active",
"phase": "idle",
"tokens_used": 0,
"elapsed_ms": 0,
"total_deliverables": 0,
"completed_deliverables": 0,
"total_worker_rounds": 0,
"total_verify_rounds": 0
}"#;
let update: SessionUpdate = serde_json::from_str(json).unwrap();
match update {
SessionUpdate::GoalUpdated {
goal_id,
token_budget,
current_deliverable_id,
live_subagent_tokens,
last_event,
..
} => {
assert_eq!(goal_id, "g-bc");
assert_eq!(token_budget, None);
assert_eq!(current_deliverable_id, None);
assert_eq!(live_subagent_tokens, None);
assert_eq!(last_event, None);
}
other => panic!("expected GoalUpdated, got {other:?}"),
}
}
// ── ModelChanged (leader-mode multi-client model switch fan-out) ──
/// Wire format for `ModelChanged` — sanity-check the JSON exactly,
/// since the pager and any third-party clients consume this on the wire.
/// Specifically:
/// - `sessionUpdate` tag is the snake_case variant name.
/// - Field names use Rust snake_case (struct fields are not subject to
/// `rename_all` — that only renames the tag).
/// - `reasoning_effort` is omitted entirely when `None` (smaller wire +
/// distinguishable from explicitly-cleared-by-user, if that ever
/// becomes a real distinction).
#[test]
fn model_changed_serializes_snake_case_with_optional_effort() {
let with_effort = SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: Some("high".into()),
};
let json = serde_json::to_value(&with_effort).unwrap();
assert_eq!(json["sessionUpdate"], "model_changed");
assert_eq!(json["model_id"], "kigi-4");
assert_eq!(json["reasoning_effort"], "high");
let without_effort = SessionUpdate::ModelChanged {
model_id: "kigi-3".into(),
reasoning_effort: None,
};
let json = serde_json::to_value(&without_effort).unwrap();
assert_eq!(json["sessionUpdate"], "model_changed");
assert_eq!(json["model_id"], "kigi-3");
assert!(
json.get("reasoning_effort").is_none(),
"reasoning_effort: None must be skipped on the wire so old pagers \
and third-party ACP clients see a smaller, no-extra-keys payload"
);
}
/// `ModelChanged` round-trips through JSON: a follower client deserializes
/// the exact same value the agent serialized. Pins the field order /
/// case so an accidental rename doesn't silently degrade to `Unknown`
/// (the `#[serde(other)]` catch-all would swallow that on the pager side
/// and break multi-client model sync without any test failing).
#[test]
fn model_changed_roundtrips_through_json() {
let original = SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: Some("medium".into()),
};
let json_str = serde_json::to_string(&original).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(original, parsed);
}
/// Wrap `ModelChanged` in the full `SessionNotification` envelope and
/// confirm the result is what the leader's session-scoped fan-out keys
/// on: top-level `sessionId` (camelCase from the envelope's
/// `rename_all`) + nested `update.sessionUpdate == "model_changed"`.
/// Without the top-level `sessionId`, the leader's `extract_session_id`
/// returns `None` and the notification falls through to the
/// last-active-client fallback instead of broadcasting — that would
/// silently break the entire multi-client sync.
#[test]
fn model_changed_envelope_carries_session_id_at_top_level() {
let notif = SessionNotification {
session_id: acp::SessionId::new("sess-abc"),
update: SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: None,
},
meta: None,
};
let json = serde_json::to_value(&notif).unwrap();
assert_eq!(json["sessionId"], "sess-abc");
assert_eq!(json["update"]["sessionUpdate"], "model_changed");
assert_eq!(json["update"]["model_id"], "kigi-4");
}
// ── TurnCompleted (durable, replayable turn-end signal) ──
#[test]
fn turn_completed_serializes_snake_case_tag_and_fields() {
// Mirrors the SubagentProgress convention: `rename_all = "snake_case"`
// only renames the tag, so struct fields keep their Rust snake_case
// names on the wire.
let update = SessionUpdate::TurnCompleted {
prompt_id: "p-1".into(),
stop_reason: "end_turn".into(),
agent_result: Some("done".into()),
usage: None,
};
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["sessionUpdate"], "turn_completed");
assert_eq!(json["prompt_id"], "p-1");
assert_eq!(json["stop_reason"], "end_turn");
assert_eq!(json["agent_result"], "done");
}
#[test]
fn turn_completed_optional_fields_skipped_when_none() {
let update = SessionUpdate::TurnCompleted {
prompt_id: "p-2".into(),
stop_reason: "cancelled".into(),
agent_result: None,
usage: None,
};
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["sessionUpdate"], "turn_completed");
assert!(json.get("agent_result").is_none());
}
#[test]
fn turn_completed_roundtrips_through_json() {
for update in [
SessionUpdate::TurnCompleted {
prompt_id: "p-rt".into(),
stop_reason: "end_turn".into(),
agent_result: Some("result text".into()),
usage: None,
},
SessionUpdate::TurnCompleted {
prompt_id: "p-min".into(),
stop_reason: "error".into(),
agent_result: None,
usage: None,
},
] {
let json_str = serde_json::to_string(&update).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, parsed);
}
}
#[test]
fn project_result_hides_costs_when_partial_or_incomplete() {
let mut model_usage = indexmap::IndexMap::new();
model_usage.insert(
"m".into(),
PromptUsageModel {
input_tokens: 100,
cached_read_tokens: 40,
output_tokens: 10,
total_tokens: 110,
model_calls: 4,
cost_usd_ticks: Some(1_000_000_000),
..Default::default()
},
);
let partial = PromptUsage {
totals: PromptUsageModel {
input_tokens: 100,
cached_read_tokens: 40,
output_tokens: 10,
total_tokens: 110,
model_calls: 5,
cost_usd_ticks: Some(1_000_000_000),
cost_is_partial: true,
cost_missing_calls: 1,
..Default::default()
},
model_usage: model_usage.clone(),
num_turns: 2,
usage_is_incomplete: false,
};
let mut result = serde_json::json!({});
project_result_usage(&mut result, &partial);
assert_eq!(result["usage"]["input_tokens"], 60);
assert!(result.get("total_cost_usd").is_none());
assert!(result.get("total_cost_usd_ticks").is_none());
assert_eq!(result["cost_is_partial"], true);
assert!(result["modelUsage"]["m"].get("costUSD").is_none());
let mut incomplete = PromptUsage {
totals: PromptUsageModel {
input_tokens: 50,
output_tokens: 5,
total_tokens: 55,
model_calls: 1,
cost_usd_ticks: Some(5_000_000_000),
..Default::default()
},
model_usage,
num_turns: 1,
usage_is_incomplete: true,
};
incomplete.scrub_untrustworthy_costs();
assert!(incomplete.totals.cost_usd_ticks.is_none());
let mut result = serde_json::json!({});
project_result_usage(&mut result, &incomplete);
assert_eq!(result["usage_is_incomplete"], true);
assert!(result.get("total_cost_usd").is_none());
assert!(result.get("total_cost_usd_ticks").is_none());
assert!(result["modelUsage"]["m"].get("costUSD").is_none());
}
#[test]
fn project_result_incomplete_empty_omits_zero_usage() {
let usage = PromptUsage::project_from_ledger(None, true).unwrap();
let mut result = serde_json::json!({});
project_result_usage(&mut result, &usage);
assert_eq!(result["usage_is_incomplete"], true);
assert!(result.get("usage").is_none());
assert!(result.get("num_turns").is_none());
assert!(result.get("total_cost_usd").is_none());
}
#[test]
fn attach_result_usage_fail_closed_on_parse_error() {
let mut result = serde_json::json!({"ok": true});
attach_result_usage_fail_closed(&mut result, &serde_json::json!("not-an-object"));
assert_eq!(result["usage_is_incomplete"], true);
assert!(result.get("usage").is_none());
assert_eq!(result["ok"], true);
}
#[test]
fn cost_missing_calls_not_on_acp_wire() {
let model = PromptUsageModel {
input_tokens: 1,
cost_missing_calls: 3,
cost_is_partial: true,
..Default::default()
};
let v = serde_json::to_value(&model).unwrap();
assert!(v.get("costMissingCalls").is_none());
assert_eq!(v["costIsPartial"], true);
}
#[test]
fn scrub_untrustworthy_costs_clears_ticks_when_partial() {
let mut usage = PromptUsage {
totals: PromptUsageModel {
input_tokens: 10,
output_tokens: 1,
total_tokens: 11,
cost_usd_ticks: Some(100),
cost_is_partial: true,
cost_missing_calls: 1,
..Default::default()
},
model_usage: Default::default(),
num_turns: 1,
usage_is_incomplete: false,
};
usage.scrub_untrustworthy_costs();
assert!(usage.totals.cost_usd_ticks.is_none());
assert!(usage.totals.cost_is_partial);
}
#[test]
fn project_result_token_identity_uncached_plus_cache_plus_output() {
let mut model_usage = indexmap::IndexMap::new();
model_usage.insert(
"m".into(),
PromptUsageModel {
input_tokens: 100,
cached_read_tokens: 40,
output_tokens: 10,
total_tokens: 110,
model_calls: 1,
cost_usd_ticks: Some(2_000_000_000),
..Default::default()
},
);
let usage = PromptUsage {
totals: PromptUsageModel {
input_tokens: 100,
cached_read_tokens: 40,
output_tokens: 10,
total_tokens: 110,
model_calls: 1,
cost_usd_ticks: Some(2_000_000_000),
..Default::default()
},
model_usage,
num_turns: 1,
usage_is_incomplete: false,
};
let mut result = serde_json::json!({});
project_result_usage(&mut result, &usage);
let uncached = result["usage"]["input_tokens"].as_u64().unwrap();
let cache = result["usage"]["cache_read_input_tokens"].as_u64().unwrap();
let output = result["usage"]["output_tokens"].as_u64().unwrap();
let total = result["usage"]["total_tokens"].as_u64().unwrap();
assert_eq!(uncached, 60);
assert_eq!(cache, 40);
assert_eq!(output, 10);
assert_eq!(total, uncached + cache + output);
// ACP serde keeps full input_tokens; headless identity differs.
let acp = serde_json::to_value(&usage).unwrap();
assert_eq!(acp["inputTokens"], 100);
assert_eq!(acp["cachedReadTokens"], 40);
assert_ne!(acp["inputTokens"], result["usage"]["input_tokens"]);
assert_eq!(result["modelUsage"]["m"]["inputTokens"], 60);
assert_eq!(result["modelUsage"]["m"]["cacheReadInputTokens"], 40);
assert_eq!(result["total_cost_usd"], 0.2);
// Exact ticks accompany the float for tick-exact reconciliation.
assert_eq!(result["total_cost_usd_ticks"], 2_000_000_000_i64);
}
#[test]
fn turn_completed_missing_required_fields_fail_to_deserialize() {
let missing_prompt_id = r#"{"sessionUpdate": "turn_completed", "stop_reason": "end_turn"}"#;
assert!(serde_json::from_str::<SessionUpdate>(missing_prompt_id).is_err());
let missing_stop_reason = r#"{"sessionUpdate": "turn_completed", "prompt_id": "p-1"}"#;
assert!(serde_json::from_str::<SessionUpdate>(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);
}
}