Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1008 lines
34 KiB
Rust
1008 lines
34 KiB
Rust
//! TodoWrite — new-architecture implementation.
|
||
//!
|
||
//! Reuses the core logic (`validate_no_duplicate_ids`, `apply_replace`,
|
||
//! `apply_merge`, `summarize_todo_state`) from the old `implementations::todo`
|
||
//! module. State is stored as `State<TodoState>` in Resources instead of
|
||
//! `ToolState.todo_state`.
|
||
|
||
use std::fmt::Write;
|
||
|
||
use crate::types::output::{TodoWriteOutput, TodoWriteSuccess};
|
||
use crate::types::requirements::{Expr, ToolRequirement};
|
||
#[allow(unused_imports)]
|
||
use crate::types::resources::{SharedResources, State};
|
||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||
|
||
#[derive(thiserror::Error, Debug)]
|
||
pub enum TodoError {
|
||
#[error("Missing Todo content in mode: {0}")]
|
||
MissingTodoContent(String),
|
||
|
||
#[error("Missing Todo ID in mode: {0}")]
|
||
MissingTodoID(String),
|
||
|
||
#[error("Duplicate Todo ID in response: {0}")]
|
||
DuplicateTodoID(String),
|
||
}
|
||
|
||
pub(crate) fn validate_no_duplicate_ids(updates: &[TodoUpdate]) -> Result<(), TodoError> {
|
||
use std::collections::HashSet;
|
||
let mut seen = HashSet::with_capacity(updates.len());
|
||
if let Some(dup) = updates.iter().map(|u| &u.id).find(|id| !seen.insert(*id)) {
|
||
return Err(TodoError::DuplicateTodoID(dup.to_owned()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `merge=false`: the incoming list fully replaces the existing todo state.
|
||
/// If `content` is omitted for an item, the `id` is used as a fallback.
|
||
/// If `status` is omitted, it defaults to `Pending`.
|
||
pub(crate) fn apply_replace(
|
||
state: &mut TodoState,
|
||
updates: &[TodoUpdate],
|
||
) -> Result<(), TodoError> {
|
||
state.clear();
|
||
for u in updates {
|
||
let content = if u.has_no_content() {
|
||
u.id.clone()
|
||
} else {
|
||
u.content.clone().unwrap()
|
||
};
|
||
let status = u.status.unwrap_or(TodoStatus::Pending);
|
||
state.push(
|
||
u.id.clone(),
|
||
TodoItem {
|
||
content,
|
||
priority: TodoPriority::default(),
|
||
status,
|
||
meta: None,
|
||
},
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `merge=true`: updates are merged into the existing state.
|
||
/// - **Existing items**: `content` is optional — if omitted the previous
|
||
/// value is kept. This lets the model mark an item from `in_progress` →
|
||
/// `completed` without echoing the content back.
|
||
/// - **New items** (id not yet in state): if `content` is omitted the `id`
|
||
/// is used as a fallback so the tool never errors on a merge call. This
|
||
/// makes the tool resilient to state being lost between calls.
|
||
pub(crate) fn apply_merge(state: &mut TodoState, updates: &[TodoUpdate]) -> Result<(), TodoError> {
|
||
for u in updates {
|
||
if state.update(&u.id, u.content.as_deref(), u.status) {
|
||
// Existing item – partial update succeeded, content was optional.
|
||
continue;
|
||
}
|
||
let content = if u.has_no_content() {
|
||
u.id.clone()
|
||
} else {
|
||
u.content.clone().unwrap()
|
||
};
|
||
let status = u.status.unwrap_or(TodoStatus::Pending);
|
||
state.push(
|
||
u.id.clone(),
|
||
TodoItem {
|
||
content,
|
||
priority: TodoPriority::default(),
|
||
status,
|
||
meta: None,
|
||
},
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn summarize_todo_state(state: &TodoState) -> String {
|
||
if state.is_empty() {
|
||
"No tasks currently tracked.".into()
|
||
} else {
|
||
let mut out = String::new();
|
||
for (id, t) in state.todo_items_with_ids() {
|
||
writeln!(&mut out, "- {} {id}: {}", t.status.tag(), t.content).ok();
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
use indexmap::IndexMap;
|
||
use schemars::JsonSchema;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
pub type TodoId = String;
|
||
|
||
// diff from acp: default to medium
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum TodoPriority {
|
||
High,
|
||
#[default]
|
||
Medium,
|
||
Low,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum TodoStatus {
|
||
Pending,
|
||
InProgress,
|
||
Completed,
|
||
Cancelled,
|
||
}
|
||
|
||
impl TodoStatus {
|
||
pub const fn tag(&self) -> &str {
|
||
match self {
|
||
Self::Pending => "[pending]",
|
||
Self::InProgress => "[in_progress]",
|
||
Self::Completed => "[completed]",
|
||
Self::Cancelled => "[cancelled]",
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct TodoItem {
|
||
pub content: String,
|
||
#[serde(default)]
|
||
pub priority: TodoPriority,
|
||
pub status: TodoStatus,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub meta: Option<serde_json::Value>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct TodoState {
|
||
todos: IndexMap<TodoId, TodoItem>,
|
||
}
|
||
|
||
crate::register_resource!("grok_build", "Todo", TodoState);
|
||
|
||
impl TodoState {
|
||
pub fn push(&mut self, id: TodoId, todo: TodoItem) {
|
||
self.todos.insert(id, todo);
|
||
}
|
||
|
||
pub fn clear(&mut self) {
|
||
self.todos.clear();
|
||
}
|
||
|
||
pub fn update(
|
||
&mut self,
|
||
id: &TodoId,
|
||
content: Option<&str>,
|
||
status: Option<TodoStatus>,
|
||
) -> bool {
|
||
let Some(todo) = self.todos.get_mut(id) else {
|
||
return false;
|
||
};
|
||
if let Some(content) = content
|
||
&& !content.is_empty()
|
||
{
|
||
todo.content = content.into();
|
||
}
|
||
if let Some(status) = status {
|
||
todo.status = status;
|
||
}
|
||
true
|
||
}
|
||
|
||
pub fn todo_items(&self) -> impl Iterator<Item = &TodoItem> + '_ {
|
||
self.todos.values()
|
||
}
|
||
|
||
pub fn todo_items_with_ids(&self) -> impl Iterator<Item = (&TodoId, &TodoItem)> + '_ {
|
||
self.todos.iter()
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.todos.is_empty()
|
||
}
|
||
|
||
pub fn has_id(&self, id: &str) -> bool {
|
||
self.todos.contains_key(id)
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct TodoUpdate {
|
||
#[schemars(description = "Unique identifier for the todo item")]
|
||
pub id: String,
|
||
|
||
#[schemars(description = "The description/content of the todo item")]
|
||
pub content: Option<String>,
|
||
|
||
#[schemars(
|
||
description = "The status of the todo item: pending, in_progress, completed, or cancelled"
|
||
)]
|
||
pub status: Option<TodoStatus>,
|
||
}
|
||
|
||
impl TodoUpdate {
|
||
/// True when the update carries no meaningful content (None or empty string).
|
||
fn has_no_content(&self) -> bool {
|
||
self.content.as_deref().is_none_or(str::is_empty)
|
||
}
|
||
}
|
||
|
||
const fn default_merge() -> bool {
|
||
true
|
||
}
|
||
|
||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct TodoWriteInput {
|
||
/// When true (the default), merge the provided todos into the existing
|
||
/// list by id (partial updates are allowed — leave unchanged fields
|
||
/// undefined). When explicitly set to false, the provided todos replace
|
||
/// the existing list entirely.
|
||
#[serde(
|
||
default = "default_merge",
|
||
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
|
||
)]
|
||
#[schemars(
|
||
description = "Optional. When true (default), merges the provided todos into the existing list by id — send only the items you are changing, and to flip status without changing content send just id + status. When false, the provided todos replace the existing list."
|
||
)]
|
||
pub merge: bool,
|
||
|
||
#[schemars(description = "Array of todo items to write to the workspace")]
|
||
pub todos: Vec<TodoUpdate>,
|
||
}
|
||
|
||
/// New-architecture `TodoWrite` tool.
|
||
///
|
||
/// State: `State<TodoState>` — persisted across calls via Resources serde.
|
||
/// Params: `()` — no per-tool configuration.
|
||
#[derive(Debug, Default)]
|
||
pub struct TodoWriteTool;
|
||
|
||
impl crate::types::tool_metadata::ToolMetadata for TodoWriteTool {
|
||
fn kind(&self) -> ToolKind {
|
||
ToolKind::Plan
|
||
}
|
||
|
||
fn tool_namespace(&self) -> ToolNamespace {
|
||
ToolNamespace::GrokBuild
|
||
}
|
||
|
||
fn description_template(&self) -> &str {
|
||
r#"Create and manage a structured task list. The user sees this list live — it is your primary way to show progress.
|
||
|
||
Use for any task with 3+ steps. Skip for trivial single-step work."#
|
||
}
|
||
|
||
fn requires_expr(&self) -> Expr<ToolRequirement> {
|
||
Expr::True
|
||
}
|
||
}
|
||
|
||
impl kigi_tool_runtime::Tool for TodoWriteTool {
|
||
type Args = TodoWriteInput;
|
||
type Output = TodoWriteOutput;
|
||
|
||
fn id(&self) -> kigi_tool_protocol::ToolId {
|
||
kigi_tool_protocol::ToolId::new("todo_write").expect("valid tool id")
|
||
}
|
||
|
||
fn description(
|
||
&self,
|
||
_ctx: &::kigi_tool_runtime::ListToolsContext,
|
||
) -> kigi_tool_types::ToolDescription {
|
||
kigi_tool_types::ToolDescription::new(
|
||
"todo_write",
|
||
crate::types::tool_metadata::ToolMetadata::description_template(self),
|
||
)
|
||
}
|
||
|
||
fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities {
|
||
kigi_tool_protocol::ToolCapabilities {
|
||
is_read_only: true,
|
||
tool_scope: Some(kigi_tool_protocol::ToolScope::Read),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
#[tracing::instrument(
|
||
name = "new_tool.todo_write",
|
||
skip_all,
|
||
fields(merge = %input.merge, todo_count = input.todos.len())
|
||
)]
|
||
async fn run(
|
||
&self,
|
||
ctx: kigi_tool_runtime::ToolCallContext,
|
||
input: TodoWriteInput,
|
||
) -> Result<TodoWriteOutput, kigi_tool_runtime::ToolError> {
|
||
use crate::types::tool_metadata::shared_resources;
|
||
let resources = shared_resources(&ctx)?;
|
||
|
||
// Validate IDs upfront — return an error-as-output variant so the
|
||
// Python side can distinguish this from infra errors.
|
||
if let Err(TodoError::DuplicateTodoID(id)) = validate_no_duplicate_ids(&input.todos) {
|
||
return Ok(TodoWriteOutput::DuplicateId(format!(
|
||
"Duplicate todo ID in request: \"{id}\". Each todo item must have a unique ID."
|
||
)));
|
||
}
|
||
|
||
let (summary_for_prompt, todos, state_snapshot);
|
||
{
|
||
let mut res = resources.lock().await;
|
||
let todo_state = res.get_or_default::<State<TodoState>>();
|
||
|
||
// Auto-upgrade to merge when the model forgot `merge: true` but
|
||
// clearly intended a partial update: state already has items and
|
||
// every update targets an existing ID without providing content.
|
||
let effective_merge = input.merge
|
||
|| (!todo_state.0.is_empty()
|
||
&& !input.todos.is_empty()
|
||
&& input
|
||
.todos
|
||
.iter()
|
||
.all(|u| u.has_no_content() && todo_state.0.has_id(&u.id)));
|
||
|
||
if effective_merge {
|
||
apply_merge(&mut todo_state.0, &input.todos)?;
|
||
} else {
|
||
apply_replace(&mut todo_state.0, &input.todos)?;
|
||
}
|
||
|
||
summary_for_prompt = summarize_todo_state(&todo_state.0);
|
||
todos = todo_state.0.todo_items().cloned().collect::<Vec<_>>();
|
||
state_snapshot = todo_state.0.clone();
|
||
}
|
||
|
||
Ok(TodoWriteOutput::TodosUpdated(TodoWriteSuccess {
|
||
summary_for_prompt,
|
||
todos,
|
||
state: state_snapshot,
|
||
}))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::types::output::TodoWriteOutput;
|
||
use crate::types::resources::Resources;
|
||
use crate::types::tool_metadata::test_ctx;
|
||
|
||
// -- Helpers --
|
||
|
||
fn make_update(id: &str, content: Option<&str>, status: Option<TodoStatus>) -> TodoUpdate {
|
||
TodoUpdate {
|
||
id: id.to_owned(),
|
||
content: content.map(str::to_owned),
|
||
status,
|
||
}
|
||
}
|
||
|
||
/// Unwrap a `TodoWriteOutput` expecting the `TodosUpdated` variant.
|
||
fn expect_success(output: TodoWriteOutput) -> TodoWriteSuccess {
|
||
match output {
|
||
TodoWriteOutput::TodosUpdated(s) => s,
|
||
other => panic!("expected TodosUpdated, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// -- Tests --
|
||
|
||
#[test]
|
||
fn name_and_description() {
|
||
use crate::types::tool_metadata::ToolMetadata;
|
||
let tool = TodoWriteTool;
|
||
assert_eq!(kigi_tool_runtime::Tool::id(&tool).as_str(), "todo_write");
|
||
assert!(ToolMetadata::description_template(&tool).contains("task list"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn replace_mode_creates_items() {
|
||
let tool = TodoWriteTool;
|
||
let resources = Resources::new();
|
||
|
||
let input = TodoWriteInput {
|
||
merge: false,
|
||
todos: vec![
|
||
make_update("1", Some("Task A"), Some(TodoStatus::Pending)),
|
||
make_update("2", Some("Task B"), Some(TodoStatus::InProgress)),
|
||
],
|
||
};
|
||
|
||
let shared = resources.into_shared();
|
||
let output = expect_success(
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input)
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(output.todos.len(), 2);
|
||
assert!(output.summary_for_prompt.contains("Task A"));
|
||
assert!(output.summary_for_prompt.contains("Task B"));
|
||
|
||
// State persists in Resources
|
||
let res = shared.lock().await;
|
||
let state = res.get::<State<TodoState>>().unwrap();
|
||
assert_eq!(state.0.todo_items().count(), 2);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn replace_clears_previous_state() {
|
||
let tool = TodoWriteTool;
|
||
let resources = Resources::new();
|
||
let shared = resources.into_shared();
|
||
|
||
// Seed initial state
|
||
let input1 = TodoWriteInput {
|
||
merge: false,
|
||
todos: vec![make_update(
|
||
"old",
|
||
Some("Old task"),
|
||
Some(TodoStatus::Completed),
|
||
)],
|
||
};
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input1)
|
||
.await
|
||
.unwrap();
|
||
|
||
// Replace with new
|
||
let input2 = TodoWriteInput {
|
||
merge: false,
|
||
todos: vec![make_update(
|
||
"new",
|
||
Some("New task"),
|
||
Some(TodoStatus::Pending),
|
||
)],
|
||
};
|
||
let output = expect_success(
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input2)
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(output.todos.len(), 1);
|
||
assert!(output.summary_for_prompt.contains("New task"));
|
||
assert!(!output.summary_for_prompt.contains("Old task"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn merge_mode_updates_existing() {
|
||
let tool = TodoWriteTool;
|
||
let resources = Resources::new();
|
||
let shared = resources.into_shared();
|
||
|
||
// Create initial items
|
||
let input1 = TodoWriteInput {
|
||
merge: false,
|
||
todos: vec![
|
||
make_update("1", Some("Build project"), Some(TodoStatus::InProgress)),
|
||
make_update("2", Some("Run tests"), Some(TodoStatus::Pending)),
|
||
],
|
||
};
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input1)
|
||
.await
|
||
.unwrap();
|
||
|
||
// Merge: mark item 1 completed (no content), add item 3
|
||
let input2 = TodoWriteInput {
|
||
merge: true,
|
||
todos: vec![
|
||
make_update("1", None, Some(TodoStatus::Completed)),
|
||
make_update("3", Some("Deploy"), Some(TodoStatus::Pending)),
|
||
],
|
||
};
|
||
let output = expect_success(
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input2)
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(output.todos.len(), 3);
|
||
|
||
// Item 1 content preserved, status updated
|
||
let item1 = output
|
||
.todos
|
||
.iter()
|
||
.find(|t| t.content == "Build project")
|
||
.unwrap();
|
||
assert_eq!(item1.status, TodoStatus::Completed);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn merge_with_lost_state_uses_id_fallback() {
|
||
let tool = TodoWriteTool;
|
||
let resources = Resources::new();
|
||
|
||
// Merge into empty state — should not error
|
||
let input = TodoWriteInput {
|
||
merge: true,
|
||
todos: vec![make_update("explore", None, Some(TodoStatus::Completed))],
|
||
};
|
||
let output = expect_success(
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(output.todos.len(), 1);
|
||
// Id used as fallback content
|
||
assert_eq!(output.todos[0].content, "explore");
|
||
assert_eq!(output.todos[0].status, TodoStatus::Completed);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn duplicate_ids_rejected() {
|
||
let tool = TodoWriteTool;
|
||
let resources = Resources::new();
|
||
|
||
let input = TodoWriteInput {
|
||
merge: false,
|
||
todos: vec![
|
||
make_update("dup", Some("A"), Some(TodoStatus::Pending)),
|
||
make_update("dup", Some("B"), Some(TodoStatus::Pending)),
|
||
],
|
||
};
|
||
let result = kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
|
||
.await
|
||
.unwrap();
|
||
assert!(
|
||
matches!(result, TodoWriteOutput::DuplicateId(ref msg) if msg.contains("dup")),
|
||
"expected DuplicateId variant, got {result:?}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn empty_todos_shows_no_tasks_message() {
|
||
let tool = TodoWriteTool;
|
||
let resources = Resources::new();
|
||
|
||
let input = TodoWriteInput {
|
||
merge: false,
|
||
todos: vec![],
|
||
};
|
||
let output = expect_success(
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
assert!(output.summary_for_prompt.contains("No tasks"));
|
||
assert!(output.todos.is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn state_output_includes_snapshot() {
|
||
let tool = TodoWriteTool;
|
||
let resources = Resources::new();
|
||
|
||
let input = TodoWriteInput {
|
||
merge: false,
|
||
todos: vec![make_update("1", Some("Task"), Some(TodoStatus::Pending))],
|
||
};
|
||
let output = expect_success(
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
|
||
// state field should match what's in Resources
|
||
assert!(!output.state.is_empty());
|
||
assert_eq!(output.state.todo_items().count(), 1);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn state_serialization_roundtrip() {
|
||
let tool = TodoWriteTool;
|
||
let mut resources = Resources::new();
|
||
resources.register_state::<TodoState>();
|
||
|
||
// Create some state
|
||
let input = TodoWriteInput {
|
||
merge: false,
|
||
todos: vec![
|
||
make_update("1", Some("First"), Some(TodoStatus::Completed)),
|
||
make_update("2", Some("Second"), Some(TodoStatus::InProgress)),
|
||
],
|
||
};
|
||
let shared = resources.into_shared();
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input)
|
||
.await
|
||
.unwrap();
|
||
|
||
// Serialize
|
||
let res = shared.lock().await;
|
||
let snapshot = res.serialize();
|
||
let state_map = snapshot.get("state").unwrap();
|
||
assert!(
|
||
state_map.get("grok_build.Todo").is_some(),
|
||
"TodoState should serialize under 'grok_build.Todo'"
|
||
);
|
||
|
||
// Deserialize into fresh Resources
|
||
let mut resources2 = Resources::new();
|
||
resources2.register_state::<TodoState>();
|
||
let data: std::collections::HashMap<
|
||
String,
|
||
std::collections::HashMap<String, serde_json::Value>,
|
||
> = serde_json::from_value(snapshot).unwrap();
|
||
resources2.load_from(data);
|
||
|
||
// Verify state was restored
|
||
let restored = resources2.get::<State<TodoState>>().unwrap();
|
||
assert_eq!(restored.0.todo_items().count(), 2);
|
||
let items: Vec<_> = restored.0.todo_items().collect();
|
||
assert_eq!(items[0].content, "First");
|
||
assert_eq!(items[0].status, TodoStatus::Completed);
|
||
assert_eq!(items[1].content, "Second");
|
||
assert_eq!(items[1].status, TodoStatus::InProgress);
|
||
}
|
||
|
||
fn seed_state(items: &[(&str, &str, TodoStatus)]) -> TodoState {
|
||
let mut state = TodoState::default();
|
||
for (id, content, status) in items {
|
||
state.push(
|
||
id.to_string(),
|
||
TodoItem {
|
||
content: content.to_string(),
|
||
priority: TodoPriority::default(),
|
||
status: *status,
|
||
meta: None,
|
||
},
|
||
);
|
||
}
|
||
state
|
||
}
|
||
|
||
fn get_item<'a>(state: &'a TodoState, id: &str) -> &'a TodoItem {
|
||
state
|
||
.todo_items_with_ids()
|
||
.find(|(i, _)| *i == id)
|
||
.map(|(_, item)| item)
|
||
.unwrap_or_else(|| panic!("item {id} not found in state"))
|
||
}
|
||
|
||
// ── replace (merge=false) ────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn replace_without_content_falls_back_to_id() {
|
||
let mut state = TodoState::default();
|
||
let updates = vec![make_update(
|
||
"build_project",
|
||
None,
|
||
Some(TodoStatus::Pending),
|
||
)];
|
||
apply_replace(&mut state, &updates).unwrap();
|
||
|
||
let item = get_item(&state, "build_project");
|
||
assert_eq!(item.content, "build_project"); // id used as fallback
|
||
assert_eq!(item.status, TodoStatus::Pending);
|
||
}
|
||
|
||
#[test]
|
||
fn replace_without_content_or_status_defaults() {
|
||
let mut state = TodoState::default();
|
||
let updates = vec![make_update("task_1", None, None)];
|
||
apply_replace(&mut state, &updates).unwrap();
|
||
|
||
let item = get_item(&state, "task_1");
|
||
assert_eq!(item.content, "task_1");
|
||
assert_eq!(item.status, TodoStatus::Pending);
|
||
}
|
||
|
||
#[test]
|
||
fn replace_with_content_succeeds() {
|
||
let mut state = TodoState::default();
|
||
let updates = vec![
|
||
make_update("1", Some("Task A"), Some(TodoStatus::Pending)),
|
||
make_update("2", Some("Task B"), Some(TodoStatus::InProgress)),
|
||
];
|
||
apply_replace(&mut state, &updates).unwrap();
|
||
|
||
assert_eq!(get_item(&state, "1").content, "Task A");
|
||
assert_eq!(get_item(&state, "1").status, TodoStatus::Pending);
|
||
assert_eq!(get_item(&state, "2").content, "Task B");
|
||
assert_eq!(get_item(&state, "2").status, TodoStatus::InProgress);
|
||
}
|
||
|
||
#[test]
|
||
fn replace_clears_previous_state_unit() {
|
||
let mut state = seed_state(&[("old", "Old task", TodoStatus::Completed)]);
|
||
let updates = vec![make_update(
|
||
"new",
|
||
Some("New task"),
|
||
Some(TodoStatus::Pending),
|
||
)];
|
||
apply_replace(&mut state, &updates).unwrap();
|
||
|
||
// Old item is gone.
|
||
assert!(!state.todo_items_with_ids().any(|(id, _)| *id == "old"));
|
||
assert_eq!(get_item(&state, "new").content, "New task");
|
||
}
|
||
|
||
// ── merge (merge=true) ───────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn merge_existing_item_status_only() {
|
||
// The core use-case: mark in_progress → completed without sending content.
|
||
let mut state = seed_state(&[("1", "Build the project", TodoStatus::InProgress)]);
|
||
let updates = vec![make_update("1", None, Some(TodoStatus::Completed))];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
let item = get_item(&state, "1");
|
||
assert_eq!(item.status, TodoStatus::Completed);
|
||
assert_eq!(item.content, "Build the project"); // unchanged
|
||
}
|
||
|
||
#[test]
|
||
fn merge_existing_item_content_and_status() {
|
||
let mut state = seed_state(&[("1", "Old text", TodoStatus::Pending)]);
|
||
let updates = vec![make_update(
|
||
"1",
|
||
Some("New text"),
|
||
Some(TodoStatus::InProgress),
|
||
)];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
let item = get_item(&state, "1");
|
||
assert_eq!(item.content, "New text");
|
||
assert_eq!(item.status, TodoStatus::InProgress);
|
||
}
|
||
|
||
#[test]
|
||
fn merge_existing_item_no_fields_is_noop() {
|
||
let mut state = seed_state(&[("1", "Keep me", TodoStatus::Pending)]);
|
||
let updates = vec![make_update("1", None, None)];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
let item = get_item(&state, "1");
|
||
assert_eq!(item.content, "Keep me");
|
||
assert_eq!(item.status, TodoStatus::Pending);
|
||
}
|
||
|
||
#[test]
|
||
fn merge_new_item_without_content_uses_id_fallback() {
|
||
// When state is empty (e.g. lost between calls) and content is None,
|
||
// the id is used as fallback content instead of erroring.
|
||
let mut state = TodoState::default();
|
||
let updates = vec![make_update(
|
||
"explore_codebase",
|
||
None,
|
||
Some(TodoStatus::Completed),
|
||
)];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
let item = get_item(&state, "explore_codebase");
|
||
assert_eq!(item.content, "explore_codebase"); // id used as fallback
|
||
assert_eq!(item.status, TodoStatus::Completed);
|
||
}
|
||
|
||
#[test]
|
||
fn merge_new_item_without_content_or_status_defaults_to_pending() {
|
||
let mut state = TodoState::default();
|
||
let updates = vec![make_update("task_1", None, None)];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
let item = get_item(&state, "task_1");
|
||
assert_eq!(item.content, "task_1");
|
||
assert_eq!(item.status, TodoStatus::Pending);
|
||
}
|
||
|
||
#[test]
|
||
fn merge_new_item_with_content_succeeds() {
|
||
let mut state = TodoState::default();
|
||
let updates = vec![make_update(
|
||
"1",
|
||
Some("Fresh task"),
|
||
Some(TodoStatus::Pending),
|
||
)];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
assert_eq!(get_item(&state, "1").content, "Fresh task");
|
||
}
|
||
|
||
#[test]
|
||
fn merge_mixed_existing_and_new() {
|
||
let mut state = seed_state(&[("exist", "Existing task", TodoStatus::InProgress)]);
|
||
let updates = vec![
|
||
// Update existing — content omitted, just flip status.
|
||
make_update("exist", None, Some(TodoStatus::Completed)),
|
||
// Brand-new item — content required.
|
||
make_update("fresh", Some("New task"), Some(TodoStatus::Pending)),
|
||
];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
let existing = get_item(&state, "exist");
|
||
assert_eq!(existing.status, TodoStatus::Completed);
|
||
assert_eq!(existing.content, "Existing task"); // preserved
|
||
|
||
let fresh = get_item(&state, "fresh");
|
||
assert_eq!(fresh.content, "New task");
|
||
assert_eq!(fresh.status, TodoStatus::Pending);
|
||
}
|
||
|
||
// ── duplicate id validation ──────────────────────────────────────
|
||
|
||
#[test]
|
||
fn duplicate_ids_rejected_unit() {
|
||
let updates = vec![
|
||
make_update("dup", Some("A"), Some(TodoStatus::Pending)),
|
||
make_update("dup", Some("B"), Some(TodoStatus::Pending)),
|
||
];
|
||
let err = validate_no_duplicate_ids(&updates).unwrap_err();
|
||
assert!(matches!(err, TodoError::DuplicateTodoID(ref id) if id == "dup"));
|
||
}
|
||
|
||
#[test]
|
||
fn unique_ids_accepted() {
|
||
let updates = vec![
|
||
make_update("a", Some("A"), Some(TodoStatus::Pending)),
|
||
make_update("b", Some("B"), Some(TodoStatus::Pending)),
|
||
];
|
||
validate_no_duplicate_ids(&updates).unwrap();
|
||
}
|
||
|
||
// ── regression: missing merge=true auto-upgrade ────────────────────
|
||
|
||
#[tokio::test]
|
||
async fn missing_merge_flag_auto_upgrades_when_status_only() {
|
||
// Regression: status-only update without merge=true must not wipe content.
|
||
let tool = TodoWriteTool;
|
||
let resources = Resources::new();
|
||
let shared = resources.into_shared();
|
||
|
||
// Create todos with content
|
||
let input1 = TodoWriteInput {
|
||
merge: false,
|
||
todos: vec![
|
||
make_update("1", Some("Explore codebase"), Some(TodoStatus::InProgress)),
|
||
make_update("2", Some("Review tools"), Some(TodoStatus::Pending)),
|
||
make_update("3", Some("Write tests"), Some(TodoStatus::Pending)),
|
||
],
|
||
};
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input1)
|
||
.await
|
||
.unwrap();
|
||
|
||
// Status-only update without merge=true
|
||
let input2 = TodoWriteInput {
|
||
merge: false, // model forgot merge: true
|
||
todos: vec![
|
||
make_update("1", None, Some(TodoStatus::Completed)),
|
||
make_update("2", None, Some(TodoStatus::Completed)),
|
||
make_update("3", None, Some(TodoStatus::InProgress)),
|
||
],
|
||
};
|
||
let output = expect_success(
|
||
kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input2)
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
|
||
// Content must be preserved, not replaced with id fallback.
|
||
assert_eq!(output.todos.len(), 3);
|
||
assert_eq!(output.todos[0].content, "Explore codebase");
|
||
assert_eq!(output.todos[0].status, TodoStatus::Completed);
|
||
assert_eq!(output.todos[1].content, "Review tools");
|
||
assert_eq!(output.todos[1].status, TodoStatus::Completed);
|
||
assert_eq!(output.todos[2].content, "Write tests");
|
||
assert_eq!(output.todos[2].status, TodoStatus::InProgress);
|
||
}
|
||
|
||
// ── regression: merge with null content should never error ────────
|
||
|
||
#[test]
|
||
fn merge_after_replace_status_update_with_null_content() {
|
||
// Reproduces the exact scenario from the bug report:
|
||
// 1. Replace creates 3 items
|
||
// 2. Merge updates 2 items with content=null, status changed
|
||
let mut state = TodoState::default();
|
||
|
||
// Step 1: replace (merge=false)
|
||
let initial = vec![
|
||
make_update(
|
||
"explore_codebase",
|
||
Some("Explore django/db/backends/sqlite3/"),
|
||
Some(TodoStatus::InProgress),
|
||
),
|
||
make_update(
|
||
"analyze_and_propose",
|
||
Some("Analyze current SQLite min version"),
|
||
Some(TodoStatus::Pending),
|
||
),
|
||
make_update(
|
||
"implementation",
|
||
Some("Update version checks"),
|
||
Some(TodoStatus::Pending),
|
||
),
|
||
];
|
||
apply_replace(&mut state, &initial).unwrap();
|
||
|
||
// Step 2: merge (merge=true) — content=null, just status changes
|
||
let updates = vec![
|
||
make_update("explore_codebase", None, Some(TodoStatus::Completed)),
|
||
make_update("analyze_and_propose", None, Some(TodoStatus::InProgress)),
|
||
];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
// Statuses flipped, content preserved from step 1.
|
||
assert_eq!(
|
||
get_item(&state, "explore_codebase").status,
|
||
TodoStatus::Completed
|
||
);
|
||
assert_eq!(
|
||
get_item(&state, "explore_codebase").content,
|
||
"Explore django/db/backends/sqlite3/"
|
||
);
|
||
assert_eq!(
|
||
get_item(&state, "analyze_and_propose").status,
|
||
TodoStatus::InProgress
|
||
);
|
||
assert_eq!(
|
||
get_item(&state, "analyze_and_propose").content,
|
||
"Analyze current SQLite min version"
|
||
);
|
||
// Third item unchanged.
|
||
assert_eq!(
|
||
get_item(&state, "implementation").status,
|
||
TodoStatus::Pending
|
||
);
|
||
}
|
||
|
||
// ── regression: empty-string content must not wipe existing content ──
|
||
|
||
#[test]
|
||
fn merge_existing_item_empty_string_content_preserves_original() {
|
||
// Model sends content: "" instead of omitting it. Must not wipe.
|
||
let mut state = seed_state(&[("1", "Build the project", TodoStatus::InProgress)]);
|
||
let updates = vec![make_update("1", Some(""), Some(TodoStatus::Completed))];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
let item = get_item(&state, "1");
|
||
assert_eq!(item.status, TodoStatus::Completed);
|
||
assert_eq!(item.content, "Build the project"); // unchanged
|
||
}
|
||
|
||
#[test]
|
||
fn replace_empty_string_content_falls_back_to_id() {
|
||
let mut state = TodoState::default();
|
||
let updates = vec![make_update("task_1", Some(""), Some(TodoStatus::Pending))];
|
||
apply_replace(&mut state, &updates).unwrap();
|
||
|
||
assert_eq!(get_item(&state, "task_1").content, "task_1");
|
||
}
|
||
|
||
#[test]
|
||
fn merge_new_item_empty_string_content_falls_back_to_id() {
|
||
let mut state = TodoState::default();
|
||
let updates = vec![make_update("task_1", Some(""), Some(TodoStatus::Pending))];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
assert_eq!(get_item(&state, "task_1").content, "task_1");
|
||
}
|
||
|
||
#[test]
|
||
fn merge_with_null_content_and_lost_state() {
|
||
// Same scenario but state was lost between calls (empty state).
|
||
// The tool should still not error — falls back to id as content.
|
||
let mut state = TodoState::default();
|
||
|
||
let updates = vec![
|
||
make_update("explore_codebase", None, Some(TodoStatus::Completed)),
|
||
make_update("analyze_and_propose", None, Some(TodoStatus::InProgress)),
|
||
];
|
||
apply_merge(&mut state, &updates).unwrap();
|
||
|
||
assert_eq!(
|
||
get_item(&state, "explore_codebase").content,
|
||
"explore_codebase"
|
||
);
|
||
assert_eq!(
|
||
get_item(&state, "explore_codebase").status,
|
||
TodoStatus::Completed
|
||
);
|
||
assert_eq!(
|
||
get_item(&state, "analyze_and_propose").content,
|
||
"analyze_and_propose"
|
||
);
|
||
assert_eq!(
|
||
get_item(&state, "analyze_and_propose").status,
|
||
TodoStatus::InProgress
|
||
);
|
||
}
|
||
}
|