Files
Kigi-CLI/crates/codegen/kigi-tools/src/implementations/kigi/todo/mod.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -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!("kigi", "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::Kigi
}
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("kigi.Todo").is_some(),
"TodoState should serialize under 'kigi.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
);
}
}