返回 CodeWhale
plans.rs
根目录 / crates / tui / src / runtime_api / plans.rs
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
537 lines RUST