docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -116,7 +116,7 @@ impl ChatStateActor {
|
||||
/// Dispatch a command to the appropriate mutation or query handler.
|
||||
fn handle_command(&mut self, cmd: ChatStateCommand) {
|
||||
match cmd {
|
||||
// ═══ Mutations ═══
|
||||
// Mutations
|
||||
ChatStateCommand::PushUserMessage { item } => {
|
||||
self.push_user_message(item);
|
||||
}
|
||||
@@ -240,7 +240,7 @@ impl ChatStateActor {
|
||||
self.repair_dangling_after_harness_halt(class);
|
||||
}
|
||||
|
||||
// ═══ Queries ═══
|
||||
// Queries
|
||||
//
|
||||
// Read queries are pure reads — repair only at write boundaries:
|
||||
// `ChatState::new()` (startup) and `push_user_message()` (new turn).
|
||||
@@ -318,7 +318,7 @@ impl ChatStateActor {
|
||||
self.truncate_to_prompt_index(target_prompt_index);
|
||||
self.state.turn_capture = None;
|
||||
self.state.prompt_usage = None;
|
||||
// `harness_trace_buffer` / `harness_trace_turns` intentionally
|
||||
// `harness_trace_buffer` / `harness_trace_turns` deliberately
|
||||
// survive a rewind: the goal planner / verifier subagents
|
||||
// genuinely ran, so their sealed trace turns stay uploadable as
|
||||
// siblings even when the live turn that triggered them is undone.
|
||||
@@ -358,7 +358,7 @@ impl ChatStateActor {
|
||||
let _ = reply.send(std::mem::take(&mut self.state.harness_trace_turns));
|
||||
}
|
||||
|
||||
// ─── Narrow targeted queries ──────────────────────────────────
|
||||
// Narrow targeted queries
|
||||
ChatStateCommand::GetConversationLen { reply } => {
|
||||
let _ = reply.send(self.get_conversation_len());
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ impl ChatStateActor {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ─── Narrow targeted queries ─────────────────────────────────────────────
|
||||
// Narrow targeted queries
|
||||
|
||||
/// Return the number of items in the conversation.
|
||||
pub(super) fn get_conversation_len(&self) -> usize {
|
||||
|
||||
@@ -148,9 +148,7 @@ impl ChatStateActor {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pruning (standalone functions, no actor state needed)
|
||||
// ============================================================================
|
||||
|
||||
/// Check whether pruning should run based on context utilization.
|
||||
///
|
||||
@@ -208,9 +206,7 @@ pub(crate) fn prune_conversation(conversation: &mut [ConversationItem], config:
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Image size-gated compaction (request-copy only)
|
||||
// ============================================================================
|
||||
|
||||
/// Replaces an inline image evicted to keep the request body under the proxy's
|
||||
/// 50 MB limit. Phrased so the model treats the image as gone rather than
|
||||
@@ -376,7 +372,7 @@ fn conversation_body_bytes(conversation: &[ConversationItem]) -> usize {
|
||||
/// always retain the newest images, an image only transitions image →
|
||||
/// placeholder as *newer/larger* payloads push the body past the limit, never
|
||||
/// placeholder → image within a stable prefix. (Token compaction removes old
|
||||
/// turns wholesale and can free room to restore a previously-evicted image,
|
||||
/// turns wholesale and can free room to restore an earlier-evicted image,
|
||||
/// but that already rewrites the prefix and invalidates the server-side prompt
|
||||
/// cache, so the restore is free.)
|
||||
///
|
||||
@@ -450,19 +446,17 @@ pub(crate) fn compact_images_to_byte_budget(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memory reminder injection
|
||||
// ============================================================================
|
||||
|
||||
use crate::types::MEMORY_CONTEXT_OPEN_TAG;
|
||||
|
||||
/// Upsert a memory reminder into the conversation's system message.
|
||||
///
|
||||
/// If the first item is a `System` message, any previously injected memory
|
||||
/// reminder section is replaced in-place; otherwise the reminder is appended.
|
||||
/// If the first item is a `System` message, any existing memory reminder
|
||||
/// section is replaced in-place; otherwise the reminder is appended.
|
||||
/// If no system message exists, a new `System` item is prepended.
|
||||
///
|
||||
/// Returns `true` when the conversation was changed.
|
||||
/// Returns `true` when the conversation changed.
|
||||
pub(super) fn inject_memory_reminder(items: &mut Vec<ConversationItem>, reminder: &str) -> bool {
|
||||
let reminder = reminder.trim();
|
||||
if reminder.is_empty() {
|
||||
@@ -505,9 +499,7 @@ fn upsert_memory_reminder_text(system_prompt: &mut std::sync::Arc<str>, reminder
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// String helpers
|
||||
// ============================================================================
|
||||
|
||||
fn safe_char_slice(s: &str, start: usize, count: usize) -> String {
|
||||
s.chars().skip(start).take(count).collect()
|
||||
@@ -529,9 +521,12 @@ mod tests {
|
||||
fn should_prune_gating() {
|
||||
use std::num::NonZeroU64;
|
||||
let cw = NonZeroU64::new(10000).unwrap();
|
||||
assert!(!should_prune(1000, cw)); // 10%
|
||||
assert!(should_prune(6000, cw)); // 60%
|
||||
assert!(!should_prune(5000, cw)); // 50% exact (> not >=)
|
||||
// 10%
|
||||
assert!(!should_prune(1000, cw));
|
||||
// 60%
|
||||
assert!(should_prune(6000, cw));
|
||||
// 50% exact (> not >=)
|
||||
assert!(!should_prune(5000, cw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -558,7 +553,8 @@ mod tests {
|
||||
assert!(sys.content.contains("Remember: user likes rust"));
|
||||
assert!(sys.content.starts_with("You are helpful."));
|
||||
}
|
||||
assert_eq!(items.len(), 2); // no new item added
|
||||
// no new item added
|
||||
assert_eq!(items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -569,7 +565,7 @@ mod tests {
|
||||
assert!(matches!(&items[0], ConversationItem::System(_)));
|
||||
}
|
||||
|
||||
// -- image size-gated compaction tests --
|
||||
// image size-gated compaction tests
|
||||
|
||||
/// A user message with a small fixed inline image.
|
||||
fn user_with_image(text: &str) -> ConversationItem {
|
||||
@@ -661,8 +657,10 @@ mod tests {
|
||||
// dropping a *batch* of the oldest, not just the one image needed to
|
||||
// clear the trigger. This is the hysteresis that keeps the prefix
|
||||
// cache-warm for the following turns.
|
||||
let img_bytes = 1_000_000usize; // ~1 MB url each
|
||||
let n = (IMAGE_COMPACT_TRIGGER_BYTES / img_bytes) + 2; // body just over trigger
|
||||
// ~1 MB url each
|
||||
let img_bytes = 1_000_000usize;
|
||||
// body just over trigger
|
||||
let n = (IMAGE_COMPACT_TRIGGER_BYTES / img_bytes) + 2;
|
||||
let mut conv: Vec<ConversationItem> = (0..n)
|
||||
.map(|i| user_with_image_of_bytes(&format!("i{i}"), img_bytes))
|
||||
.collect();
|
||||
@@ -726,7 +724,7 @@ mod tests {
|
||||
assert!(has_placeholder(&conv[0]));
|
||||
}
|
||||
|
||||
// -- conversation_body_bytes tests --
|
||||
// conversation_body_bytes tests
|
||||
|
||||
#[test]
|
||||
fn conversation_body_bytes_empty_is_json_array() {
|
||||
@@ -777,7 +775,7 @@ mod tests {
|
||||
assert!(conversation_body_bytes(&conv) >= IMAGE_COMPACT_TRIGGER_BYTES);
|
||||
}
|
||||
|
||||
// -- edge cases: exactness, boundaries, ordering --
|
||||
// edge cases: exactness, boundaries, ordering
|
||||
|
||||
#[test]
|
||||
fn body_bytes_parity_multi_image_unicode_escaping() {
|
||||
|
||||
@@ -137,7 +137,7 @@ pub(crate) struct ChatState {
|
||||
/// Opaque credential secrets (api key, optional extra auth, client version).
|
||||
/// Stored opaquely — the actor never interprets them.
|
||||
pub credentials: Credentials,
|
||||
/// Bytes/4 estimate of tokens added since the last `record_token_usage`.
|
||||
/// Bytes/4 estimate of tokens accumulated since the last `record_token_usage`.
|
||||
/// Used by `check_preflight_overflow` to detect context window overflows
|
||||
/// between model responses.
|
||||
pub estimated_tokens_since_model: u64,
|
||||
@@ -304,7 +304,8 @@ mod tests {
|
||||
fn new_state_has_correct_defaults() {
|
||||
let state = ChatState::new(vec![], test_sampling_config());
|
||||
assert_eq!(state.prompt_index, 0);
|
||||
assert_eq!(state.total_tokens, 0); // empty conversation → 0
|
||||
// empty conversation → 0
|
||||
assert_eq!(state.total_tokens, 0);
|
||||
assert!(state.conversation.is_empty());
|
||||
assert!(state.agent_edited_paths.is_empty());
|
||||
assert!(state.prompt_texts.is_empty());
|
||||
@@ -333,7 +334,8 @@ mod tests {
|
||||
ConversationItem::tool_result("call-1", "w".repeat(4000).as_str()),
|
||||
];
|
||||
let state = ChatState::new(items, test_sampling_config());
|
||||
assert_eq!(state.total_tokens, 4000); // 4 * (4000/4)
|
||||
// 4 * (4000/4)
|
||||
assert_eq!(state.total_tokens, 4000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -91,9 +91,7 @@ impl TestHarness {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lifecycle tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn actor_spawns_and_shuts_down_via_cancellation() {
|
||||
@@ -121,9 +119,7 @@ async fn actor_shuts_down_when_all_handles_dropped() {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mutation tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_user_message_appends_and_persists() {
|
||||
@@ -319,9 +315,10 @@ async fn estimated_tokens_tracks_tool_result_delta() {
|
||||
.push_tool_result(ConversationItem::tool_result("call-1", "x".repeat(4000)));
|
||||
|
||||
let estimated = h.handle.get_estimated_total_tokens().await;
|
||||
assert_eq!(estimated, 101_000); // 100K model-reported + 1K delta
|
||||
// 100K model-reported + 1K delta
|
||||
assert_eq!(estimated, 101_000);
|
||||
|
||||
// model-reported total_tokens is unchanged
|
||||
// model-reported total_tokens is `unchanged`
|
||||
let actual = h.handle.get_total_tokens().await;
|
||||
assert_eq!(actual, 100_000);
|
||||
}
|
||||
@@ -359,7 +356,7 @@ async fn estimated_tokens_tracks_synthetic_user_message_delta() {
|
||||
"expected ~1.1M tokens estimated, got {estimated}",
|
||||
);
|
||||
|
||||
// model-reported `total_tokens` is unchanged — only the delta moved.
|
||||
// model-reported `total_tokens` is `unchanged` — only the delta moved.
|
||||
assert_eq!(h.handle.get_total_tokens().await, 100_000);
|
||||
}
|
||||
|
||||
@@ -451,7 +448,7 @@ async fn replace_conversation_persists_and_emits_reset() {
|
||||
h.handle.push_user_message(ConversationItem::user("b"));
|
||||
|
||||
// Drain the two Message records
|
||||
let _ = h.handle.get_conversation().await; // sync point
|
||||
let _ = h.handle.get_conversation().await;
|
||||
h.drain_persistence();
|
||||
|
||||
let new_items = vec![ConversationItem::system("compacted")];
|
||||
@@ -721,9 +718,7 @@ async fn restore_snapshot_restores_all_fields() {
|
||||
assert_eq!(tokens, 500);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_conversation_returns_current_state() {
|
||||
@@ -765,7 +760,8 @@ async fn replace_system_head_noop_when_head_matches_modulo_newline() {
|
||||
ConversationItem::system("same\n"),
|
||||
ConversationItem::user("hi"),
|
||||
]);
|
||||
let _ = h.drain_persistence(); // clear any seed writes
|
||||
// clear any seed writes
|
||||
let _ = h.drain_persistence();
|
||||
let changed = h.handle.replace_system_head("same").await;
|
||||
assert_eq!(
|
||||
changed,
|
||||
@@ -878,9 +874,7 @@ async fn check_auto_compact_triggers_at_threshold() {
|
||||
assert_eq!(t.utilization_percent, 86);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Edge-case / integration tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_agent_edited_path_deduplicates() {
|
||||
@@ -974,19 +968,19 @@ async fn truncate_removes_items_after_target_prompt_index() {
|
||||
// Build 3 turns: system + 3x (user + assistant)
|
||||
h.handle.push_user_message(ConversationItem::system("sys"));
|
||||
h.handle.push_user_message(ConversationItem::user("q1"));
|
||||
h.handle.increment_prompt_index(); // 1
|
||||
h.handle.increment_prompt_index();
|
||||
h.handle.cache_prompt_text("q1".to_string());
|
||||
h.handle
|
||||
.push_assistant_response(ConversationItem::assistant("a1"));
|
||||
|
||||
h.handle.push_user_message(ConversationItem::user("q2"));
|
||||
h.handle.increment_prompt_index(); // 2
|
||||
h.handle.increment_prompt_index();
|
||||
h.handle.cache_prompt_text("q2".to_string());
|
||||
h.handle
|
||||
.push_assistant_response(ConversationItem::assistant("a2"));
|
||||
|
||||
h.handle.push_user_message(ConversationItem::user("q3"));
|
||||
h.handle.increment_prompt_index(); // 3
|
||||
h.handle.increment_prompt_index();
|
||||
h.handle.cache_prompt_text("q3".to_string());
|
||||
h.handle
|
||||
.push_assistant_response(ConversationItem::assistant("a3"));
|
||||
@@ -1000,7 +994,8 @@ async fn truncate_removes_items_after_target_prompt_index() {
|
||||
h.handle.truncate_to_prompt_index(1).await;
|
||||
|
||||
let conv = h.handle.get_conversation().await;
|
||||
assert_eq!(conv.len(), 3); // sys + q1 + a1
|
||||
// sys + q1 + a1
|
||||
assert_eq!(conv.len(), 3);
|
||||
let idx = h.handle.get_prompt_index().await;
|
||||
assert_eq!(idx, 1);
|
||||
|
||||
@@ -1032,7 +1027,8 @@ async fn truncate_to_zero_keeps_only_system() {
|
||||
h.handle.truncate_to_prompt_index(0).await;
|
||||
|
||||
let conv = h.handle.get_conversation().await;
|
||||
assert_eq!(conv.len(), 1); // just "sys"
|
||||
// just "sys"
|
||||
assert_eq!(conv.len(), 1);
|
||||
assert!(matches!(&conv[0], ConversationItem::System(_)));
|
||||
assert_eq!(h.handle.get_prompt_index().await, 0);
|
||||
}
|
||||
@@ -1040,7 +1036,7 @@ async fn truncate_to_zero_keeps_only_system() {
|
||||
#[tokio::test]
|
||||
async fn truncate_is_noop_when_already_at_target() {
|
||||
let mut h = TestHarness::new();
|
||||
h.handle.increment_prompt_index(); // 1
|
||||
h.handle.increment_prompt_index();
|
||||
|
||||
let _ = h.handle.get_prompt_index().await;
|
||||
h.drain_events();
|
||||
@@ -1056,9 +1052,7 @@ async fn truncate_is_noop_when_already_at_target() {
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Snapshot/restore comprehensive tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_restore_preserves_all_fields() {
|
||||
@@ -1139,9 +1133,7 @@ async fn with_initial_conversation_preserves_items() {
|
||||
assert_eq!(conv.len(), 2);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BuildConversationRequest tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_request_includes_all_messages() {
|
||||
@@ -1234,7 +1226,8 @@ async fn build_request_injects_memory_when_no_system() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(request.items.len(), 2); // new System + original User
|
||||
// new System + original User
|
||||
assert_eq!(request.items.len(), 2);
|
||||
assert!(matches!(&request.items[0], ConversationItem::System(_)));
|
||||
}
|
||||
|
||||
@@ -1341,11 +1334,12 @@ async fn build_request_does_not_mutate_actor_state() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Actor's own conversation should be unchanged
|
||||
// Actor's own conversation should be `unchanged`
|
||||
let conv = h.handle.get_conversation().await;
|
||||
assert_eq!(conv.len(), 2);
|
||||
if let ConversationItem::System(ref sys) = conv[0] {
|
||||
assert_eq!(sys.content.as_ref(), "sys"); // no memory injected into original
|
||||
// no memory injected into original
|
||||
assert_eq!(sys.content.as_ref(), "sys");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1424,9 +1418,7 @@ async fn build_request_with_multiple_tool_calls_and_results() {
|
||||
assert_eq!(request.items.len(), 6);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parallel tool calls with mixed accept/reject
|
||||
// ============================================================================
|
||||
|
||||
/// Simulates the exact sequence that `kigi-shell`'s `execute_tool_calls`
|
||||
/// produces when the model emits 3 parallel tool calls and:
|
||||
@@ -1451,7 +1443,7 @@ async fn parallel_tool_calls_accept_first_reject_second_skip_third() {
|
||||
|
||||
let h = TestHarness::new();
|
||||
|
||||
// ── Turn setup ──────────────────────────────────────────────────────
|
||||
// Turn setup
|
||||
// System prompt
|
||||
h.handle.push_user_message(ConversationItem::system(
|
||||
"You are a helpful coding assistant.",
|
||||
@@ -1464,7 +1456,7 @@ async fn parallel_tool_calls_accept_first_reject_second_skip_third() {
|
||||
|
||||
h.handle.increment_prompt_index();
|
||||
|
||||
// ── Model response: 3 parallel tool calls ───────────────────────────
|
||||
// Model response: 3 parallel tool calls
|
||||
// The model's single assistant message contains all 3 tool calls.
|
||||
// In the real code, this is built from the streaming response and pushed
|
||||
// via `push_assistant_response`.
|
||||
@@ -1493,7 +1485,7 @@ async fn parallel_tool_calls_accept_first_reject_second_skip_third() {
|
||||
});
|
||||
h.handle.push_assistant_response(assistant_with_tools);
|
||||
|
||||
// ── Tool execution results (simulating execute_tool_calls) ──────────
|
||||
// Tool execution results (simulating execute_tool_calls)
|
||||
|
||||
// Tool #1: read_file — user accepted, tool executed successfully
|
||||
h.handle.push_tool_result(ConversationItem::tool_result(
|
||||
@@ -1517,7 +1509,7 @@ async fn parallel_tool_calls_accept_first_reject_second_skip_third() {
|
||||
"Tool execution cancelled due to earlier permission rejection for tool `run_terminal_cmd`",
|
||||
));
|
||||
|
||||
// ── Verify the conversation state ───────────────────────────────────
|
||||
// Verify the conversation state
|
||||
let conv = h.handle.get_conversation().await;
|
||||
|
||||
// Expected: System + User + Assistant(3 calls) + 3 ToolResults = 6 items
|
||||
@@ -1731,9 +1723,7 @@ async fn parallel_tool_calls_with_rejection_persists_all_items() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Race condition: cancellation mid-tool-execution → dangling calls on reload
|
||||
// ============================================================================
|
||||
|
||||
/// Simulates the race condition where:
|
||||
/// 1. Model emits 3 parallel tool calls (single assistant message)
|
||||
@@ -1972,9 +1962,7 @@ async fn all_tool_calls_dangling_after_crash() {
|
||||
assert_eq!(request.items.len(), 6);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live-session cancellation: user cancels mid-tool-execution (no restart)
|
||||
// ============================================================================
|
||||
|
||||
/// Simulates an in-session abort where:
|
||||
/// 1. Model emits 3 parallel tool calls → assistant pushed to conversation
|
||||
@@ -1984,7 +1972,7 @@ async fn all_tool_calls_dangling_after_crash() {
|
||||
///
|
||||
/// This is different from the reload scenario: `ChatState::new` doesn't run
|
||||
/// again because the actor is still alive. The fix is that `push_user_message`
|
||||
/// now calls `repair_dangling_tool_calls` before appending the new user
|
||||
/// calls `repair_dangling_tool_calls` before appending the new user
|
||||
/// message, so the conversation is cleaned up in-place.
|
||||
#[tokio::test]
|
||||
async fn live_cancel_before_any_tool_execution_repairs_on_next_user_message() {
|
||||
@@ -1992,14 +1980,14 @@ async fn live_cancel_before_any_tool_execution_repairs_on_next_user_message() {
|
||||
|
||||
let h = TestHarness::new();
|
||||
|
||||
// ── Turn 1: normal conversation ─────────────────────────────────────
|
||||
// Turn 1: normal conversation
|
||||
h.handle
|
||||
.push_user_message(ConversationItem::system("You are a helpful assistant."));
|
||||
h.handle.push_user_message(ConversationItem::user("Hello"));
|
||||
h.handle
|
||||
.push_assistant_response(ConversationItem::assistant("Hi! How can I help?"));
|
||||
|
||||
// ── Turn 2: model wants 3 tool calls, user cancels immediately ──────
|
||||
// Turn 2: model wants 3 tool calls, user cancels immediately
|
||||
h.handle
|
||||
.push_user_message(ConversationItem::user("Read, edit, and test everything"));
|
||||
|
||||
@@ -2023,7 +2011,7 @@ async fn live_cancel_before_any_tool_execution_repairs_on_next_user_message() {
|
||||
},
|
||||
]));
|
||||
|
||||
// *** USER CANCELS HERE (Ctrl+C) ***
|
||||
// USER CANCELS HERE (Ctrl+C)
|
||||
// The tokio task is aborted. execute_tool_calls never ran.
|
||||
// Zero ToolResult items pushed. The conversation has dangling calls.
|
||||
|
||||
@@ -2127,7 +2115,7 @@ async fn live_cancel_after_partial_tool_results_repairs_remaining() {
|
||||
"file contents here",
|
||||
));
|
||||
|
||||
// *** USER CANCELS HERE — tool #2 and #3 never executed ***
|
||||
// USER CANCELS HERE — tool #2 and #3 never executed
|
||||
|
||||
// User types a new prompt
|
||||
h.handle.push_user_message(ConversationItem::user(
|
||||
@@ -2178,7 +2166,6 @@ async fn live_cancel_after_partial_tool_results_repairs_remaining() {
|
||||
}
|
||||
|
||||
// Turn message capture tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_capture_collects_all_message_types() {
|
||||
@@ -2537,7 +2524,6 @@ async fn turn_capture_survives_integrity_repair_prefix_shrink() {
|
||||
// Capture starts after the 7-item prefix: turn_start_offset == 7.
|
||||
h.handle.begin_turn_capture();
|
||||
|
||||
// First turn item lands while the prefix duplicates are still present.
|
||||
h.handle
|
||||
.push_assistant_response(ConversationItem::assistant("turn-1"));
|
||||
|
||||
@@ -2636,9 +2622,7 @@ async fn turn_capture_survives_persisted_memory_reminder_prepend() {
|
||||
));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Narrow targeted query tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_conversation_len_empty() {
|
||||
@@ -2795,7 +2779,7 @@ async fn get_conversation_item_at_does_not_mutate_state() {
|
||||
assert_eq!(conv.len(), 2);
|
||||
}
|
||||
|
||||
// ── Multimodal regression tests for get_first_user_text() ────────────────────
|
||||
// Multimodal regression tests for get_first_user_text()
|
||||
|
||||
/// Confirms that `get_first_user_text()` returns `None` when the first content
|
||||
/// part of the first user message is an image (not text). This preserves the
|
||||
@@ -2805,7 +2789,6 @@ async fn get_first_user_text_image_first_returns_none() {
|
||||
use kigi_sampling_types::{ContentPart, UserItem};
|
||||
|
||||
let h = TestHarness::new();
|
||||
// First message: image-only user message (no text part)
|
||||
h.handle.push_user_message(ConversationItem::User(UserItem {
|
||||
content: vec![ContentPart::Image {
|
||||
url: "data:image/png;base64,abc".into(),
|
||||
@@ -2865,7 +2848,7 @@ async fn get_first_user_text_text_then_image_returns_text() {
|
||||
assert_eq!(text.as_deref(), Some("look at this"));
|
||||
}
|
||||
|
||||
// ── Tests for GetLastUserQueryText, GetConversationCounts, GetSystemMessage ───
|
||||
// Tests for GetLastUserQueryText, GetConversationCounts, GetSystemMessage
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_last_user_query_text_empty_conversation() {
|
||||
@@ -2935,18 +2918,17 @@ async fn get_system_message_returns_first_system() {
|
||||
assert!(matches!(sys, ConversationItem::System(s) if s.content.as_ref() == "You are helpful."));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Subagent bootstrap regression tests
|
||||
//
|
||||
// These verify that `replace_conversation` correctly syncs the system prompt
|
||||
// into a ChatStateActor that was spawned before the prompt was built — the
|
||||
// exact sequence used by `spawn_session_actor` for subagents.
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_subagent_bootstrap_has_system_message_after_replace() {
|
||||
// Simulate a fresh (non-forked) subagent: actor starts with an empty conversation.
|
||||
let h = TestHarness::new(); // spawns with vec![]
|
||||
// spawns with vec![]
|
||||
let h = TestHarness::new();
|
||||
|
||||
// At this point the actor has no system message, mirroring the bug.
|
||||
assert!(h.handle.get_system_message().await.is_none());
|
||||
@@ -3005,9 +2987,7 @@ async fn forked_subagent_bootstrap_replaces_parent_system_message() {
|
||||
assert_eq!(conv.len(), 3);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// In-memory retained pruning tests (PR3)
|
||||
// ============================================================================
|
||||
|
||||
/// Helper: push N complete turns (user + assistant + tool-result) so the
|
||||
/// conversation grows to a predictable length.
|
||||
@@ -3165,8 +3145,10 @@ async fn prune_retained_bounds_long_session_footprint() {
|
||||
use crate::persistence::MockChatPersistence;
|
||||
use crate::types::PruningConfig;
|
||||
|
||||
const TURNS: usize = 50; // enough turns to clear many old tool results
|
||||
const CONTENT_LEN: usize = 50_000; // 50 KB per tool result
|
||||
// enough turns to clear many old tool results
|
||||
const TURNS: usize = 50;
|
||||
// 50 KB per tool result
|
||||
const CONTENT_LEN: usize = 50_000;
|
||||
const PLACEHOLDER_LEN: usize = "[Tool result omitted — too old]".len();
|
||||
|
||||
let (mock, _rx) = MockChatPersistence::new();
|
||||
@@ -3324,7 +3306,8 @@ async fn prune_retained_synthetic_user_does_not_advance_age() {
|
||||
// Three real turns, each with a large tool result.
|
||||
for i in 0..3usize {
|
||||
handle.push_user_message(ConversationItem::user(format!("real q{i}")));
|
||||
handle.increment_prompt_index(); // prompt_index = i+1
|
||||
// prompt_index = i+1
|
||||
handle.increment_prompt_index();
|
||||
handle.push_assistant_response(ConversationItem::assistant(format!("a{i}")));
|
||||
handle.push_tool_result(ConversationItem::tool_result(
|
||||
format!("call_{i}"),
|
||||
@@ -3339,7 +3322,8 @@ async fn prune_retained_synthetic_user_does_not_advance_age() {
|
||||
|
||||
// Fourth real turn starts: prompt_index → 4, pruning fires inside push_user_message.
|
||||
handle.push_user_message(ConversationItem::user("real q3"));
|
||||
handle.increment_prompt_index(); // prompt_index = 4
|
||||
// prompt_index = 4
|
||||
handle.increment_prompt_index();
|
||||
|
||||
// Sync
|
||||
let conv = handle.get_conversation().await;
|
||||
@@ -3616,7 +3600,6 @@ async fn context_window_downgrade_triggers_auto_compact() {
|
||||
"api_backend must not change"
|
||||
);
|
||||
|
||||
// Now auto-compact sees the 128k window and fires
|
||||
let trigger = h.handle.check_auto_compact_needed(85).await;
|
||||
assert!(
|
||||
trigger.is_some(),
|
||||
@@ -3633,14 +3616,13 @@ async fn context_window_downgrade_triggers_auto_compact() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// KV Cache Prefix Stability Tests
|
||||
//
|
||||
// These test `build_conversation_request()` output prefix stability through
|
||||
// the full pipeline -- pruning, memory injection, image pruning, snapshot
|
||||
// restore. Prefix stability within a compaction epoch is the invariant that
|
||||
// keeps the inference engine's prefix / KV cache hitting. The sibling-Reasoning refactor
|
||||
// deleted the placeholder/splice machinery these tests previously had to work
|
||||
// deleted the placeholder/splice machinery these tests earlier had to work
|
||||
// around.
|
||||
//
|
||||
// These target the refactored sibling-Reasoning shape:
|
||||
@@ -3649,7 +3631,6 @@ async fn context_window_downgrade_triggers_auto_compact() {
|
||||
// - Reasoning lives as `ConversationItem::Reasoning(rs::ReasoningItem)`
|
||||
// siblings; the From<&ConversationRequest> for rs::CreateResponse impl
|
||||
// emits them inline in `input` order.
|
||||
// ============================================================================
|
||||
|
||||
/// Serialize a ConversationRequest using only the public
|
||||
/// `From<&ConversationRequest> for rs::CreateResponse` trait impl.
|
||||
@@ -4049,8 +4030,6 @@ async fn prefix_stable_after_image_pruning() {
|
||||
|
||||
// Image stripping mutates the old user turn's content, so full
|
||||
// byte-level prefix stability cannot hold at that item. We verify:
|
||||
// 1. System prompt preserved
|
||||
// 2. Items grew
|
||||
// 3. Text items appear in the same relative order
|
||||
let body1 = serialize_via_public_api(&req1);
|
||||
let body2 = serialize_via_public_api(&req2);
|
||||
@@ -4164,7 +4143,8 @@ async fn prefix_stable_after_tool_result_pruning() {
|
||||
h.handle
|
||||
.push_tool_result(ConversationItem::tool_result("c2", "y".repeat(500)));
|
||||
h.handle.push_user_message(ConversationItem::user("q3"));
|
||||
h.handle.record_token_usage(6000); // > 50% of 10k context
|
||||
// > 50% of 10k context
|
||||
h.handle.record_token_usage(6000);
|
||||
|
||||
let req2 = h
|
||||
.handle
|
||||
@@ -4319,9 +4299,7 @@ async fn prefix_stable_after_session_resume() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Out-of-band history repair (kigi/session/repair)
|
||||
// ============================================================================
|
||||
|
||||
/// Bricked-session shape: an orphaned tool result survives load (the eager
|
||||
/// repairs only fix dangling calls) and 400s on every request. The
|
||||
|
||||
@@ -37,7 +37,7 @@ impl std::error::Error for RepairHistoryBlocked {}
|
||||
|
||||
/// Commands sent to the ChatStateActor via mpsc channel.
|
||||
pub enum ChatStateCommand {
|
||||
// ═══ Mutations (fire-and-forget) ═══
|
||||
// Mutations (fire-and-forget)
|
||||
/// Push a user message into the conversation.
|
||||
PushUserMessage { item: ConversationItem },
|
||||
|
||||
@@ -64,7 +64,7 @@ pub enum ChatStateCommand {
|
||||
RecordTokenUsage { total_tokens: u64 },
|
||||
|
||||
/// Stash the per-turn `TokenUsage` from the most recent model response.
|
||||
/// Overwrites any previously stashed value.
|
||||
/// Overwrites any earlier stashed value.
|
||||
RecordLastTurnUsage { usage: TokenUsage },
|
||||
|
||||
RecordModelCallUsage {
|
||||
@@ -176,7 +176,7 @@ pub enum ChatStateCommand {
|
||||
/// Repair dangling tool calls after a harness-initiated halt.
|
||||
RepairDanglingAfterHarnessHalt { class: &'static str },
|
||||
|
||||
// ═══ Queries (request/response via oneshot) ═══
|
||||
// Queries (request/response via oneshot)
|
||||
/// Build a ConversationRequest ready to send to the API.
|
||||
/// Clones the conversation, prunes old tool results, repairs dangling
|
||||
/// tool calls, injects memory reminder, and assembles the request.
|
||||
@@ -280,7 +280,7 @@ pub enum ChatStateCommand {
|
||||
reply: oneshot::Sender<Vec<Vec<ConversationItem>>>,
|
||||
},
|
||||
|
||||
// ═══ Narrow targeted queries (avoid full-conversation clone) ═══
|
||||
// Narrow targeted queries (avoid full-conversation clone)
|
||||
/// Get the number of items in the conversation.
|
||||
/// Cheaper than `GetConversation` when only the length is needed.
|
||||
GetConversationLen { reply: oneshot::Sender<usize> },
|
||||
|
||||
@@ -32,7 +32,7 @@ impl CompactionMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the detail level if this is `Segments`, else unchanged. Lets the
|
||||
/// Replace the detail level if this is `Segments`, else `unchanged`. Lets the
|
||||
/// resolver attach the separately-resolved `KIGI_COMPACTION_DETAIL`.
|
||||
pub fn with_segment_detail(self, detail: CompactionDetail) -> Self {
|
||||
match self {
|
||||
|
||||
@@ -77,7 +77,7 @@ pub const INDEX_HEADER: &str = "# Compaction Segment Index\n\n\
|
||||
| Segment | File | Turns | Approx bytes | Keywords |\n\
|
||||
|---|---|---|---|---|\n";
|
||||
|
||||
/// Zero-padded segment number, e.g. `007`. The single source of the pad width.
|
||||
/// Zero-`padded` segment number, e.g. `007`. The single source of the pad width.
|
||||
fn segment_label(index: u64) -> String {
|
||||
format!("{index:03}")
|
||||
}
|
||||
@@ -724,7 +724,7 @@ mod tests {
|
||||
assert_eq!(classify_compaction_path("compaction/notes.md"), None);
|
||||
}
|
||||
|
||||
// --- Parity with the Python implementation's own test vectors (compaction_utils_test.py) ---
|
||||
// Parity with the Python implementation's own test vectors (compaction_utils_test.py)
|
||||
|
||||
/// Keyword extraction: the Python `TestExtractKeywords` vectors (bare `8.`
|
||||
/// headers, stopword filtering, dedup, no-section-8 fallback) plus our
|
||||
|
||||
@@ -299,7 +299,7 @@ pub fn extract_last_user_query(conversation: &[ConversationItem]) -> Option<Stri
|
||||
.map(|item| extract_user_query(&item.text_content()))
|
||||
.filter(|q| !q.is_empty())
|
||||
}
|
||||
/// The continuation prompt added to the conversation after auto-compaction.
|
||||
/// The continuation prompt appended to the conversation after auto-compaction.
|
||||
///
|
||||
/// Stored here (rather than only in `kigi-shell`) so that query-extraction
|
||||
/// helpers in this crate can recognise and exclude it from "real user prompt"
|
||||
@@ -666,7 +666,7 @@ pub fn format_compact_summary(summary: &str) -> String {
|
||||
/// A markdown "**Analysis**"-style header has no opening `<analysis>` tag for
|
||||
/// step 1 to catch; it ends at an orphan `</analysis>`. Everything up to and
|
||||
/// including the *last* `</analysis>` is dropped, so a scratchpad that itself
|
||||
/// quotes `</analysis>` mid-reasoning is still removed whole. The peel is
|
||||
/// quotes `</analysis>` mid-reasoning is still stripped whole. The peel is
|
||||
/// skipped when the block already starts with a numbered section — including a
|
||||
/// markdown-decorated one like `## 1.` or `**1.**` — so a `</analysis>` merely
|
||||
/// echoed inside a real section never truncates the summary. Any leftover
|
||||
|
||||
@@ -34,7 +34,7 @@ impl ChatStateHandle {
|
||||
Self { cmd_tx }
|
||||
}
|
||||
|
||||
// ═══ Fire-and-forget mutations ═══
|
||||
// Fire-and-forget mutations
|
||||
|
||||
/// Push a user message into the conversation.
|
||||
pub fn push_user_message(&self, item: ConversationItem) {
|
||||
@@ -238,7 +238,6 @@ impl ChatStateHandle {
|
||||
.send(ChatStateCommand::UpdateCredentials { credentials });
|
||||
}
|
||||
|
||||
/// Restore from a snapshot.
|
||||
pub fn restore_snapshot(&self, snapshot: ChatStateSnapshot) {
|
||||
let _ = self
|
||||
.cmd_tx
|
||||
@@ -280,7 +279,7 @@ impl ChatStateHandle {
|
||||
.send(ChatStateCommand::RepairDanglingAfterHarnessHalt { class });
|
||||
}
|
||||
|
||||
// ═══ Async queries (via oneshot) ═══
|
||||
// Async queries (via oneshot)
|
||||
|
||||
/// Send a query to the actor and await the reply.
|
||||
///
|
||||
@@ -419,7 +418,6 @@ impl ChatStateHandle {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get sampling config.
|
||||
pub async fn get_sampling_config(&self) -> Option<SamplingConfig> {
|
||||
self.query("GetSamplingConfig", |reply| {
|
||||
ChatStateCommand::GetSamplingConfig { reply }
|
||||
@@ -501,7 +499,6 @@ impl ChatStateHandle {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Check if auto-compact is needed.
|
||||
pub async fn check_auto_compact_needed(
|
||||
&self,
|
||||
threshold_percent: u8,
|
||||
@@ -516,7 +513,7 @@ impl ChatStateHandle {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
// ═══ Narrow targeted queries ═══
|
||||
// Narrow targeted queries
|
||||
|
||||
/// Get the number of items in the conversation.
|
||||
///
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! kigi-chat-state — Actor-based chat state management for xAI agents.
|
||||
//!
|
||||
//! This crate extracts conversation state management from `kigi-shell`'s
|
||||
//! `acp_session.rs` into a standalone actor. It follows the same actor pattern
|
||||
//! as `kigi-hunk-tracker`:
|
||||
//! Holds the conversation state driven by `kigi-shell`'s `acp_session.rs`,
|
||||
//! following the same actor pattern as `kigi-hunk-tracker`:
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌────────────────┐ ┌──────────────────────────────────────┐
|
||||
@@ -35,7 +34,6 @@ pub mod persistence;
|
||||
pub mod types;
|
||||
pub mod usage;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use actor::ChatStateActor;
|
||||
pub use actor::state::{
|
||||
estimate_conversation_tokens, estimate_item_tokens, estimate_messages_tokens,
|
||||
|
||||
@@ -27,9 +27,7 @@ pub trait ChatPersistence: Send + 'static {
|
||||
fn flush(&mut self);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock (test double) — channel-based, no locks, no atomics
|
||||
// ============================================================================
|
||||
|
||||
/// A record of a persistence call, sent over a channel to the test.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -101,9 +99,7 @@ impl ChatPersistence for MockChatPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Null (noop) — for benchmarks / scenarios where persistence is unwanted
|
||||
// ============================================================================
|
||||
|
||||
/// No-op implementation: discards everything (for benchmarks / noop scenarios).
|
||||
pub struct NullChatPersistence;
|
||||
|
||||
@@ -13,54 +13,46 @@ use serde::{Deserialize, Serialize};
|
||||
/// an injected block.
|
||||
pub const MEMORY_CONTEXT_OPEN_TAG: &str = "<memory-context>";
|
||||
|
||||
/// Closing tag paired with [`MEMORY_CONTEXT_OPEN_TAG`].
|
||||
pub const MEMORY_CONTEXT_CLOSE_TAG: &str = "</memory-context>";
|
||||
|
||||
/// Configuration for the ChatStateActor at spawn time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatStateConfig {
|
||||
/// Initial conversation items to populate the state with.
|
||||
pub initial_conversation: Vec<ConversationItem>,
|
||||
/// Sampling configuration (model, context window, etc.).
|
||||
pub sampling_config: SamplingConfig,
|
||||
}
|
||||
|
||||
/// Immutable snapshot of the actor's state (for forking, rewind).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatStateSnapshot {
|
||||
/// The full conversation history.
|
||||
pub conversation: Vec<ConversationItem>,
|
||||
/// Current sampling configuration.
|
||||
pub sampling_config: SamplingConfig,
|
||||
/// Current prompt index (incremented per user turn).
|
||||
/// Incremented per user turn.
|
||||
pub prompt_index: usize,
|
||||
/// Accumulated token usage.
|
||||
pub total_tokens: u64,
|
||||
/// Bytes/4 estimate of the conversation as of the last `record_token_usage`.
|
||||
/// `0` means unknown (pre-field snapshot); restore re-estimates instead.
|
||||
/// `0` means unknown (snapshot written without the field); restore
|
||||
/// re-estimates instead.
|
||||
#[serde(default)]
|
||||
pub estimate_at_last_response: u64,
|
||||
/// File paths the agent has edited.
|
||||
pub agent_edited_paths: BTreeSet<String>,
|
||||
/// Cached prompt texts for rewind preview.
|
||||
/// Cached for rewind preview.
|
||||
pub prompt_texts: Vec<String>,
|
||||
/// Timestamp when the current stream started (epoch ms).
|
||||
/// Epoch ms.
|
||||
pub stream_start_ms: Option<i64>,
|
||||
/// Timestamp when the current turn started (epoch ms).
|
||||
/// Epoch ms.
|
||||
pub turn_start_ms: Option<i64>,
|
||||
/// Prompt index at which the last compaction occurred.
|
||||
pub last_compaction_prompt_index: Option<usize>,
|
||||
/// Opaque credential secrets (API key, optional extra auth, client version).
|
||||
#[serde(default)]
|
||||
pub credentials: Credentials,
|
||||
}
|
||||
|
||||
/// Metadata for session notifications (timing info).
|
||||
/// Timing metadata for session notifications.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotificationMeta {
|
||||
/// Timestamp when the current stream started (epoch ms).
|
||||
/// Epoch ms.
|
||||
pub stream_start_ms: Option<i64>,
|
||||
/// Timestamp when the current turn started (epoch ms).
|
||||
/// Epoch ms.
|
||||
pub turn_start_ms: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -70,7 +62,6 @@ pub struct NotificationMeta {
|
||||
/// Two modes: soft trim (keep head + tail) and hard clear (replace entirely).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PruningConfig {
|
||||
/// Whether pruning is enabled.
|
||||
pub enabled: bool,
|
||||
/// Number of recent turns whose tool results are never pruned.
|
||||
pub keep_last_n_turns: usize,
|
||||
@@ -116,9 +107,7 @@ pub enum AuthType {
|
||||
/// The actor just stores and returns them — it never interprets them.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Credentials {
|
||||
/// API key for authentication.
|
||||
pub api_key: Option<String>,
|
||||
/// Whether this is a session token (refreshable) or user-provided api key.
|
||||
#[serde(default)]
|
||||
pub auth_type: AuthType,
|
||||
/// Optional extra auth material forwarded with requests when present.
|
||||
@@ -130,7 +119,7 @@ pub struct Credentials {
|
||||
/// Produced by `TakeTurnMessages` after a `BeginTurnCapture`/message-push cycle.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnCapture {
|
||||
/// The ordered sequence of messages appended during this turn.
|
||||
/// In the order they were appended.
|
||||
pub messages: Vec<ConversationItem>,
|
||||
/// Whether compaction (conversation replacement) occurred mid-turn.
|
||||
pub compaction_occurred: bool,
|
||||
@@ -142,24 +131,18 @@ pub struct TurnCapture {
|
||||
/// when only role counts and total length are needed (e.g. for telemetry).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ConversationCounts {
|
||||
/// Total number of items in the conversation.
|
||||
pub total: usize,
|
||||
/// Number of `User` items.
|
||||
pub user: usize,
|
||||
/// Number of `Assistant` items.
|
||||
pub assistant: usize,
|
||||
/// Number of `ToolResult` items.
|
||||
pub tool_result: usize,
|
||||
}
|
||||
|
||||
/// Info returned when auto-compact threshold is exceeded.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoCompactTrigger {
|
||||
/// Current total token count.
|
||||
pub total_tokens: u64,
|
||||
/// Model's context window size.
|
||||
pub context_window: NonZeroU64,
|
||||
/// Current utilization as a percentage (0–100).
|
||||
/// 0–100.
|
||||
pub utilization_percent: u8,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user