Files
Kigi-CLI/crates/codegen/kigi-tools/src/implementations/grok_build/todo/mod.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
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).
2026-07-17 05:31:01 -04:00

1008 lines
34 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.
//! 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
);
}
}