| 1 | //! `/v1/plan`, `/v1/todo`, `/v1/plans`, `/v1/todos`, and the per-thread pair — |
| 2 | //! read-only inventory over the plan/todo tool receipts the runtime store |
| 3 | //! already persists on turn items. |
| 4 | //! |
| 5 | //! There is no second plan store. An `update_plan` receipt carries the plan |
| 6 | //! payload; a `todo_write` / `work_update` receipt carries the whole |
| 7 | //! checklist. The newest receipt per thread is the live projection, and |
| 8 | //! earlier `update_plan` receipts in the same thread report |
| 9 | //! `superseded: true`. The projection mirrors the GPUI client |
| 10 | //! (`codewhale-gpui/src/plans.rs`) so both surfaces read the same receipts the |
| 11 | //! same way: match on `metadata.tool_name` (never on item `kind` — the store |
| 12 | //! files `todo_write` under `file_change` because the name contains "write") |
| 13 | //! and parse the args JSON out of `metadata.tool_input`, falling back to |
| 14 | //! `detail` for seeded history receipts that predate durable `tool_input` |
| 15 | //! (the same fallback restart history rebuild uses in runtime_threads.rs). |
| 16 | //! |
| 17 | //! **What this module does not do:** it does not read the live `PlanState` / |
| 18 | //! `SharedTodoList` — those are per-engine session state, not durable |
| 19 | //! receipts, and a headless Runtime may have no engine loaded for the thread. |
| 20 | //! To-do history is not tracked: only the newest checklist receipt projects, |
| 21 | //! matching the client. And the cross-thread routes pay one |
| 22 | //! `get_thread_detail` whole-store walk per scanned thread — the store keeps |
| 23 | //! no plan/todo index — so `limit` bounds how many of the newest threads are |
| 24 | //! scanned. |
| 25 | |
| 26 | use axum::Json; |
| 27 | use axum::extract::{Path, Query, State}; |
| 28 | use serde::Serialize; |
| 29 | use serde_json::Value; |
| 30 | |
| 31 | use crate::runtime_threads::{TurnItemLifecycleStatus, TurnItemRecord}; |
| 32 | use crate::tools::plan::{PlanItemArg, PlanSnapshot}; |
| 33 | use crate::tools::todo::TodoStatus; |
| 34 | |
| 35 | use super::{ApiError, RuntimeApiState, ThreadsQuery, map_thread_err, resolve_thread_filter}; |
| 36 | |
| 37 | /// One `update_plan` receipt projected for the API. |
| 38 | #[derive(Debug, Clone, Serialize)] |
| 39 | #[serde(rename_all = "camelCase")] |
| 40 | pub(super) struct PlanRevisionView { |
| 41 | item_id: String, |
| 42 | turn_id: String, |
| 43 | /// Receipt lifecycle (`completed`, `failed`, …), not plan state. |
| 44 | status: TurnItemLifecycleStatus, |
| 45 | title: Option<String>, |
| 46 | objective: Option<String>, |
| 47 | explanation: Option<String>, |
| 48 | recommended_approach: Option<String>, |
| 49 | /// `PlanItemArg` already serializes as `{step, status}` with status in |
| 50 | /// `pending|in_progress|completed`. |
| 51 | steps: Vec<PlanItemArg>, |
| 52 | /// True when a newer `update_plan` receipt in the same thread superseded |
| 53 | /// this revision. |
| 54 | superseded: bool, |
| 55 | } |
| 56 | |
| 57 | /// A thread's current plan plus its revision history. |
| 58 | #[derive(Debug, Serialize)] |
| 59 | #[serde(rename_all = "camelCase")] |
| 60 | pub(super) struct ThreadPlanView { |
| 61 | thread_id: String, |
| 62 | /// Fields of the newest revision, flattened to the top level. |
| 63 | #[serde(flatten)] |
| 64 | current: PlanRevisionView, |
| 65 | /// Every `update_plan` receipt, newest first; `revisions[0]` is `current`. |
| 66 | revisions: Vec<PlanRevisionView>, |
| 67 | } |
| 68 | |
| 69 | /// The newest revision of one thread's plan, for `/v1/plans` rows. |
| 70 | #[derive(Debug, Serialize)] |
| 71 | #[serde(rename_all = "camelCase")] |
| 72 | pub(super) struct PlanEntryView { |
| 73 | thread_id: String, |
| 74 | #[serde(flatten)] |
| 75 | revision: PlanRevisionView, |
| 76 | } |
| 77 | |
| 78 | #[derive(Debug, Serialize)] |
| 79 | pub(super) struct PlansResponse { |
| 80 | plans: Vec<PlanEntryView>, |
| 81 | } |
| 82 | |
| 83 | #[derive(Debug, Serialize)] |
| 84 | pub(super) struct TodosResponse { |
| 85 | todos: Vec<ThreadTodoView>, |
| 86 | } |
| 87 | |
| 88 | #[derive(Debug, Clone, Serialize)] |
| 89 | #[serde(rename_all = "camelCase")] |
| 90 | pub(super) struct TodoItemView { |
| 91 | content: String, |
| 92 | status: TodoStatus, |
| 93 | #[serde(skip_serializing_if = "Option::is_none")] |
| 94 | id: Option<u32>, |
| 95 | } |
| 96 | |
| 97 | /// A thread's latest checklist receipt projected for the API. |
| 98 | #[derive(Debug, Serialize)] |
| 99 | #[serde(rename_all = "camelCase")] |
| 100 | pub(super) struct ThreadTodoView { |
| 101 | thread_id: String, |
| 102 | item_id: Option<String>, |
| 103 | turn_id: Option<String>, |
| 104 | /// Receipt tool name (`todo_write`, `work_update`, …). |
| 105 | tool: String, |
| 106 | /// Receipt lifecycle (`completed`, `failed`, …), not list state. |
| 107 | status: Option<TurnItemLifecycleStatus>, |
| 108 | items: Vec<TodoItemView>, |
| 109 | completion_pct: Option<u8>, |
| 110 | } |
| 111 | |
| 112 | fn is_plan_tool(name: &str) -> bool { |
| 113 | name == "update_plan" |
| 114 | } |
| 115 | |
| 116 | /// Names a checklist receipt can persist under: the canonical `todo_write`, |
| 117 | /// its registered compat aliases (registry.rs `with_todo_tool`), and the |
| 118 | /// `todos` name the GPUI client also recognizes. |
| 119 | fn is_todo_tool(name: &str) -> bool { |
| 120 | matches!( |
| 121 | name, |
| 122 | "todo_write" |
| 123 | | "work_update" |
| 124 | | "TodoWrite" |
| 125 | | "todo" |
| 126 | | "todos" |
| 127 | | "checklist_write" |
| 128 | | "checklist_update" |
| 129 | ) |
| 130 | } |
| 131 | |
| 132 | fn tool_name(item: &TurnItemRecord) -> &str { |
| 133 | item.metadata |
| 134 | .as_ref() |
| 135 | .and_then(|meta| meta.get("tool_name")) |
| 136 | .and_then(Value::as_str) |
| 137 | .unwrap_or_default() |
| 138 | } |
| 139 | |
| 140 | /// The receipt's args JSON. Live calls persist it as a string under |
| 141 | /// `metadata.tool_input` at `ToolCallStarted` and carry it through completion; |
| 142 | /// seeded history receipts carry `tool_name` but leave the input in `detail`, |
| 143 | /// which is why the fallback exists only when `tool_input` is absent. |
| 144 | fn tool_input(item: &TurnItemRecord) -> Option<Value> { |
| 145 | let raw = item |
| 146 | .metadata |
| 147 | .as_ref() |
| 148 | .and_then(|meta| meta.get("tool_input")) |
| 149 | .and_then(Value::as_str) |
| 150 | .map(str::to_string) |
| 151 | .or_else(|| item.detail.clone())?; |
| 152 | serde_json::from_str(&raw).ok() |
| 153 | } |
| 154 | |
| 155 | /// Every `update_plan` receipt in the thread, oldest first, with `superseded` |
| 156 | /// set on all but the newest. |
| 157 | fn plan_revisions(items: &[TurnItemRecord]) -> Vec<PlanRevisionView> { |
| 158 | let mut revisions = Vec::new(); |
| 159 | for item in items { |
| 160 | if !is_plan_tool(tool_name(item)) { |
| 161 | continue; |
| 162 | } |
| 163 | let Some(input) = tool_input(item) else { |
| 164 | continue; |
| 165 | }; |
| 166 | // The engine's canonical tolerant parser for this payload: it cleans |
| 167 | // optional strings and coerces unknown/absent step status to Pending. |
| 168 | let snapshot = PlanSnapshot::from_tool_input(&input); |
| 169 | revisions.push(PlanRevisionView { |
| 170 | item_id: item.id.clone(), |
| 171 | turn_id: item.turn_id.clone(), |
| 172 | status: item.status, |
| 173 | title: snapshot.title, |
| 174 | objective: snapshot.objective, |
| 175 | explanation: snapshot.explanation, |
| 176 | recommended_approach: snapshot.recommended_approach, |
| 177 | steps: snapshot.items, |
| 178 | superseded: false, |
| 179 | }); |
| 180 | } |
| 181 | if let Some(current_id) = revisions.last().map(|rev| rev.item_id.clone()) { |
| 182 | for rev in &mut revisions { |
| 183 | rev.superseded = rev.item_id != current_id; |
| 184 | } |
| 185 | } |
| 186 | revisions |
| 187 | } |
| 188 | |
| 189 | /// The newest checklist receipt in the thread, or `None` when it has none. |
| 190 | /// Each receipt replaces the whole list, so only the latest projects. |
| 191 | fn latest_todo(items: &[TurnItemRecord]) -> Option<ThreadTodoView> { |
| 192 | let mut latest: Option<ThreadTodoView> = None; |
| 193 | for item in items { |
| 194 | let tool = tool_name(item); |
| 195 | if !is_todo_tool(tool) { |
| 196 | continue; |
| 197 | } |
| 198 | let Some(input) = tool_input(item) else { |
| 199 | continue; |
| 200 | }; |
| 201 | let todos = input |
| 202 | .get("todos") |
| 203 | .and_then(Value::as_array) |
| 204 | .cloned() |
| 205 | .or_else(|| { |
| 206 | input |
| 207 | .pointer("/task_updates/checklist/items") |
| 208 | .and_then(Value::as_array) |
| 209 | .cloned() |
| 210 | }) |
| 211 | .unwrap_or_default(); |
| 212 | let mut steps = Vec::new(); |
| 213 | for todo in &todos { |
| 214 | let content = todo |
| 215 | .get("content") |
| 216 | .or_else(|| todo.get("text")) |
| 217 | .or_else(|| todo.get("step")) |
| 218 | .and_then(Value::as_str) |
| 219 | .map(str::trim) |
| 220 | .unwrap_or_default(); |
| 221 | if content.is_empty() { |
| 222 | continue; |
| 223 | } |
| 224 | let status = todo |
| 225 | .get("status") |
| 226 | .and_then(Value::as_str) |
| 227 | .and_then(TodoStatus::from_str) |
| 228 | .unwrap_or(TodoStatus::Pending); |
| 229 | let id = todo |
| 230 | .get("id") |
| 231 | .and_then(Value::as_u64) |
| 232 | .and_then(|n| u32::try_from(n).ok()); |
| 233 | steps.push(TodoItemView { |
| 234 | content: content.to_string(), |
| 235 | status, |
| 236 | id, |
| 237 | }); |
| 238 | } |
| 239 | let completion_pct = input |
| 240 | .get("completion_pct") |
| 241 | .or_else(|| input.pointer("/task_updates/checklist/completion_pct")) |
| 242 | .and_then(Value::as_u64) |
| 243 | .map(|n| n.min(100) as u8) |
| 244 | .or_else(|| { |
| 245 | if steps.is_empty() { |
| 246 | None |
| 247 | } else { |
| 248 | let settled = steps.iter().filter(|s| s.status.is_settled()).count(); |
| 249 | Some(((settled * 100) / steps.len()) as u8) |
| 250 | } |
| 251 | }); |
| 252 | latest = Some(ThreadTodoView { |
| 253 | thread_id: String::new(), |
| 254 | item_id: Some(item.id.clone()), |
| 255 | turn_id: Some(item.turn_id.clone()), |
| 256 | tool: tool.to_string(), |
| 257 | status: Some(item.status), |
| 258 | items: steps, |
| 259 | completion_pct, |
| 260 | }); |
| 261 | } |
| 262 | latest |
| 263 | } |
| 264 | |
| 265 | /// `GET /v1/threads/{id}/plan` — the thread's latest plan and its revisions. |
| 266 | pub(super) async fn get_thread_plan( |
| 267 | State(state): State<RuntimeApiState>, |
| 268 | Path(id): Path<String>, |
| 269 | ) -> Result<Json<ThreadPlanView>, ApiError> { |
| 270 | let detail = state |
| 271 | .runtime_threads |
| 272 | .get_thread_detail(&id) |
| 273 | .await |
| 274 | .map_err(map_thread_err)?; |
| 275 | let mut revisions = plan_revisions(&detail.items); |
| 276 | revisions.reverse(); |
| 277 | let Some(current) = revisions.first().cloned() else { |
| 278 | return Err(ApiError::not_found(format!("Thread '{id}' has no plan"))); |
| 279 | }; |
| 280 | Ok(Json(ThreadPlanView { |
| 281 | thread_id: id, |
| 282 | current, |
| 283 | revisions, |
| 284 | })) |
| 285 | } |
| 286 | |
| 287 | /// `GET /v1/threads/{id}/todo` — the thread's latest checklist. |
| 288 | pub(super) async fn get_thread_todo( |
| 289 | State(state): State<RuntimeApiState>, |
| 290 | Path(id): Path<String>, |
| 291 | ) -> Result<Json<ThreadTodoView>, ApiError> { |
| 292 | let detail = state |
| 293 | .runtime_threads |
| 294 | .get_thread_detail(&id) |
| 295 | .await |
| 296 | .map_err(map_thread_err)?; |
| 297 | let Some(mut todo) = latest_todo(&detail.items) else { |
| 298 | return Err(ApiError::not_found(format!( |
| 299 | "Thread '{id}' has no todo list" |
| 300 | ))); |
| 301 | }; |
| 302 | todo.thread_id = id; |
| 303 | Ok(Json(todo)) |
| 304 | } |
| 305 | |
| 306 | /// `GET /v1/plan` — the latest plan on the most recently updated thread that |
| 307 | /// has one. Threads are already newest-first; the first hit wins. |
| 308 | pub(super) async fn latest_plan( |
| 309 | State(state): State<RuntimeApiState>, |
| 310 | Query(query): Query<ThreadsQuery>, |
| 311 | ) -> Result<Json<ThreadPlanView>, ApiError> { |
| 312 | let filter = resolve_thread_filter(query.include_archived, query.archived_only); |
| 313 | let threads = state |
| 314 | .runtime_threads |
| 315 | .list_threads(filter, query.limit) |
| 316 | .await |
| 317 | .map_err(|e| ApiError::internal(e.to_string()))?; |
| 318 | for thread in threads { |
| 319 | let detail = state |
| 320 | .runtime_threads |
| 321 | .get_thread_detail(&thread.id) |
| 322 | .await |
| 323 | .map_err(map_thread_err)?; |
| 324 | let mut revisions = plan_revisions(&detail.items); |
| 325 | if revisions.is_empty() { |
| 326 | continue; |
| 327 | } |
| 328 | revisions.reverse(); |
| 329 | let current = revisions[0].clone(); |
| 330 | return Ok(Json(ThreadPlanView { |
| 331 | thread_id: thread.id, |
| 332 | current, |
| 333 | revisions, |
| 334 | })); |
| 335 | } |
| 336 | Err(ApiError::not_found("No thread has a plan")) |
| 337 | } |
| 338 | |
| 339 | /// `GET /v1/todo` — the latest checklist on the most recently updated thread |
| 340 | /// that has one. |
| 341 | pub(super) async fn latest_todo_route( |
| 342 | State(state): State<RuntimeApiState>, |
| 343 | Query(query): Query<ThreadsQuery>, |
| 344 | ) -> Result<Json<ThreadTodoView>, ApiError> { |
| 345 | let filter = resolve_thread_filter(query.include_archived, query.archived_only); |
| 346 | let threads = state |
| 347 | .runtime_threads |
| 348 | .list_threads(filter, query.limit) |
| 349 | .await |
| 350 | .map_err(|e| ApiError::internal(e.to_string()))?; |
| 351 | for thread in threads { |
| 352 | let detail = state |
| 353 | .runtime_threads |
| 354 | .get_thread_detail(&thread.id) |
| 355 | .await |
| 356 | .map_err(map_thread_err)?; |
| 357 | if let Some(mut todo) = latest_todo(&detail.items) { |
| 358 | todo.thread_id = thread.id; |
| 359 | return Ok(Json(todo)); |
| 360 | } |
| 361 | } |
| 362 | Err(ApiError::not_found("No thread has a todo list")) |
| 363 | } |
| 364 | |
| 365 | /// `GET /v1/plans` — every scanned thread's latest plan, newest thread first. |
| 366 | pub(super) async fn list_plans( |
| 367 | State(state): State<RuntimeApiState>, |
| 368 | Query(query): Query<ThreadsQuery>, |
| 369 | ) -> Result<Json<PlansResponse>, ApiError> { |
| 370 | let filter = resolve_thread_filter(query.include_archived, query.archived_only); |
| 371 | let threads = state |
| 372 | .runtime_threads |
| 373 | .list_threads(filter, query.limit) |
| 374 | .await |
| 375 | .map_err(|e| ApiError::internal(e.to_string()))?; |
| 376 | let mut plans = Vec::new(); |
| 377 | for thread in threads { |
| 378 | let detail = state |
| 379 | .runtime_threads |
| 380 | .get_thread_detail(&thread.id) |
| 381 | .await |
| 382 | .map_err(map_thread_err)?; |
| 383 | if let Some(revision) = plan_revisions(&detail.items).into_iter().last() { |
| 384 | plans.push(PlanEntryView { |
| 385 | thread_id: thread.id, |
| 386 | revision, |
| 387 | }); |
| 388 | } |
| 389 | } |
| 390 | Ok(Json(PlansResponse { plans })) |
| 391 | } |
| 392 | |
| 393 | /// `GET /v1/todos` — every scanned thread's latest checklist, newest thread |
| 394 | /// first. |
| 395 | pub(super) async fn list_todos( |
| 396 | State(state): State<RuntimeApiState>, |
| 397 | Query(query): Query<ThreadsQuery>, |
| 398 | ) -> Result<Json<TodosResponse>, ApiError> { |
| 399 | let filter = resolve_thread_filter(query.include_archived, query.archived_only); |
| 400 | let threads = state |
| 401 | .runtime_threads |
| 402 | .list_threads(filter, query.limit) |
| 403 | .await |
| 404 | .map_err(|e| ApiError::internal(e.to_string()))?; |
| 405 | let mut todos = Vec::new(); |
| 406 | for thread in threads { |
| 407 | let detail = state |
| 408 | .runtime_threads |
| 409 | .get_thread_detail(&thread.id) |
| 410 | .await |
| 411 | .map_err(map_thread_err)?; |
| 412 | if let Some(mut todo) = latest_todo(&detail.items) { |
| 413 | todo.thread_id = thread.id; |
| 414 | todos.push(todo); |
| 415 | } |
| 416 | } |
| 417 | Ok(Json(TodosResponse { todos })) |
| 418 | } |
| 419 | |
| 420 | #[cfg(test)] |
| 421 | mod tests { |
| 422 | use super::*; |
| 423 | use crate::runtime_threads::TurnItemKind; |
| 424 | use chrono::Utc; |
| 425 | use serde_json::json; |
| 426 | |
| 427 | fn receipt(id: &str, tool: &str, input: &Value) -> TurnItemRecord { |
| 428 | let now = Utc::now(); |
| 429 | TurnItemRecord { |
| 430 | schema_version: 2, |
| 431 | id: id.to_string(), |
| 432 | turn_id: "turn_1".to_string(), |
| 433 | kind: TurnItemKind::ToolCall, |
| 434 | status: TurnItemLifecycleStatus::Completed, |
| 435 | summary: tool.to_string(), |
| 436 | detail: Some("tool output".to_string()), |
| 437 | metadata: Some(json!({ |
| 438 | "tool_use_id": format!("call_{id}"), |
| 439 | "tool_name": tool, |
| 440 | "tool_input": input.to_string(), |
| 441 | })), |
| 442 | artifact_refs: Vec::new(), |
| 443 | started_at: Some(now), |
| 444 | ended_at: Some(now), |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | #[test] |
| 449 | fn plan_revisions_mark_all_but_newest_superseded() { |
| 450 | let items = vec![ |
| 451 | receipt( |
| 452 | "p1", |
| 453 | "update_plan", |
| 454 | &json!({ |
| 455 | "title": "First", |
| 456 | "plan": [{"step": "A", "status": "completed"}] |
| 457 | }), |
| 458 | ), |
| 459 | receipt("other", "shell", &json!({"cmd": "pwd"})), |
| 460 | receipt( |
| 461 | "p2", |
| 462 | "update_plan", |
| 463 | &json!({ |
| 464 | "objective": "Ship it", |
| 465 | "plan": [ |
| 466 | {"step": "Probe", "status": "completed"}, |
| 467 | {"step": "Land", "status": "in_progress"} |
| 468 | ] |
| 469 | }), |
| 470 | ), |
| 471 | ]; |
| 472 | let revisions = plan_revisions(&items); |
| 473 | assert_eq!(revisions.len(), 2); |
| 474 | assert!(revisions[0].superseded); |
| 475 | assert!(!revisions[1].superseded); |
| 476 | assert_eq!(revisions[1].item_id, "p2"); |
| 477 | assert_eq!(revisions[1].objective.as_deref(), Some("Ship it")); |
| 478 | assert_eq!(revisions[1].steps.len(), 2); |
| 479 | assert_eq!( |
| 480 | revisions[1].steps[1].status, |
| 481 | crate::tools::plan::StepStatus::InProgress |
| 482 | ); |
| 483 | } |
| 484 | |
| 485 | #[test] |
| 486 | fn latest_todo_keeps_only_the_newest_receipt() { |
| 487 | let items = vec![ |
| 488 | receipt( |
| 489 | "t1", |
| 490 | "todo_write", |
| 491 | &json!({"todos": [{"id": 1, "content": "old", "status": "pending"}]}), |
| 492 | ), |
| 493 | receipt( |
| 494 | "t2", |
| 495 | "work_update", |
| 496 | &json!({ |
| 497 | "todos": [ |
| 498 | {"id": 1, "content": "probe", "status": "completed"}, |
| 499 | {"id": 2, "content": "land", "status": "in_progress"}, |
| 500 | {"id": 3, "content": "push", "status": "pending"} |
| 501 | ] |
| 502 | }), |
| 503 | ), |
| 504 | ]; |
| 505 | let todo = latest_todo(&items).expect("a todo receipt exists"); |
| 506 | assert_eq!(todo.item_id.as_deref(), Some("t2")); |
| 507 | assert_eq!(todo.tool, "work_update"); |
| 508 | assert_eq!(todo.items.len(), 3); |
| 509 | assert_eq!(todo.items[1].status, TodoStatus::InProgress); |
| 510 | assert_eq!(todo.completion_pct, Some(33)); |
| 511 | assert!(latest_todo(&items[..0]).is_none()); |
| 512 | } |
| 513 | |
| 514 | #[test] |
| 515 | fn seeded_receipt_without_tool_input_reads_detail() { |
| 516 | let mut item = receipt( |
| 517 | "seeded", |
| 518 | "update_plan", |
| 519 | &json!({"title": "Seeded", "plan": [{"step": "A", "status": "pending"}]}), |
| 520 | ); |
| 521 | // Seeded history carries the args JSON in `detail`, not |
| 522 | // `metadata.tool_input` (runtime_threads.rs SeedItem::ToolUse). |
| 523 | let input = item |
| 524 | .metadata |
| 525 | .as_ref() |
| 526 | .and_then(|m| m.get("tool_input")) |
| 527 | .and_then(Value::as_str) |
| 528 | .unwrap_or_default() |
| 529 | .to_string(); |
| 530 | item.detail = Some(input); |
| 531 | item.metadata = Some(json!({"tool_use_id": "call_seeded", "tool_name": "update_plan"})); |
| 532 | let revisions = plan_revisions(&[item]); |
| 533 | assert_eq!(revisions.len(), 1); |
| 534 | assert_eq!(revisions[0].title.as_deref(), Some("Seeded")); |
| 535 | } |
| 536 | } |
| 537 |