1use crate::models::jj::jj_slug;
2use crate::models::{Task, TaskRepo, TaskStatus, Todo, TodoStatus, VcsMode, Worktree};
3use crate::services::{git_worktree, jj_task};
4use jj_task::RepoWorkspaceStatus;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum WorkflowPhase {
11 Setup,
12 SyncRequired,
13 Execute,
14 TaskComplete,
15 Archived,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum NextActionKind {
22 RunCommand,
23 ExecuteTodo,
24 UseJjSkill,
25 WaitHuman,
26}
27
28#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
30pub struct NextAction {
31 pub kind: NextActionKind,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub command: Option<String>,
34 pub reason: String,
35}
36
37#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
39pub struct WorkflowStep {
40 pub id: &'static str,
41 pub label: String,
42 pub done: bool,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub command: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
49pub struct WorkflowContext {
50 pub phase: WorkflowPhase,
51 pub next_action: NextAction,
52 #[serde(skip_serializing_if = "Vec::is_empty")]
53 pub checklist: Vec<WorkflowStep>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub enum WorkspaceLifecycle {
60 NotRequested,
61 Requested,
62 Ready,
63 Merged,
64}
65
66#[derive(Debug, Clone, Serialize)]
68pub struct TodoAgentView {
69 pub todo_id: i64,
70 pub content: String,
71 pub status: TodoStatus,
72 pub is_next: bool,
73 pub allowed_actions: Vec<String>,
74 pub workspace: WorkspaceAgentView,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct WorkspaceAgentView {
79 pub lifecycle: WorkspaceLifecycle,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub path: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub bookmark: Option<String>,
84}
85
86#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
88pub struct JjAgentContext {
89 pub slug: String,
90 pub skill: &'static str,
91 pub workspace_registered: bool,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub workspace_path: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub task_phase: Option<String>,
96 pub repos: Vec<RepoWorkspaceStatus>,
97 pub start_command: String,
98 pub path_command: String,
99 pub repo_init_command: &'static str,
100}
101
102#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
104pub struct GitAgentContext {
105 pub slug: String,
106 pub branch: String,
107 pub workspace_ready: bool,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub workspace_path: Option<String>,
110 pub sync_command: String,
111}
112
113#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
115pub struct AgentGuardrails {
116 pub must_use_jj_skill: bool,
118 pub jj_skill_name: &'static str,
119 pub reopen_forbidden: bool,
120 pub complete_requires_jj_merge: bool,
122}
123
124impl AgentGuardrails {
125 pub fn for_mode(vcs_mode: VcsMode, complete_requires_jj_merge: bool) -> Self {
126 match vcs_mode {
127 VcsMode::Jj => Self {
128 must_use_jj_skill: true,
129 jj_skill_name: "jj",
130 reopen_forbidden: true,
131 complete_requires_jj_merge,
132 },
133 VcsMode::Git => Self {
134 must_use_jj_skill: false,
135 jj_skill_name: "jj",
136 reopen_forbidden: true,
137 complete_requires_jj_merge: false,
138 },
139 }
140 }
141}
142
143impl Default for AgentGuardrails {
144 fn default() -> Self {
145 Self::for_mode(VcsMode::Jj, false)
146 }
147}
148
149fn repo_paths(repos: &[TaskRepo]) -> Vec<String> {
150 repos.iter().map(|repo| repo.repo_path.clone()).collect()
151}
152
153fn pending_needs_workspace(todos: &[Todo]) -> bool {
154 todos
155 .iter()
156 .any(|todo| todo.status == TodoStatus::Pending && todo.requires_workspace)
157}
158
159pub fn legacy_worktree_pending(todos: &[Todo]) -> bool {
161 todos
162 .iter()
163 .any(|todo| todo.status == TodoStatus::Pending && todo.worktree_requested)
164}
165
166pub fn legacy_worktree_sync_needed(todos: &[Todo], worktrees: &[Worktree]) -> bool {
168 todos.iter().any(|todo| {
169 todo.status == TodoStatus::Pending
170 && todo.worktree_requested
171 && !worktrees.iter().any(|wt| wt.todo_id == Some(todo.id))
172 })
173}
174
175fn jj_workspace_needed(task: &Task, todos: &[Todo], repos: &[TaskRepo]) -> bool {
176 if repos.is_empty() || !pending_needs_workspace(todos) {
177 return false;
178 }
179 let slug = jj_slug(task);
180 !jj_task::all_repos_registered(&slug, &repo_paths(repos))
181}
182
183fn git_workspace_needed(task: &Task, todos: &[Todo], repos: &[TaskRepo]) -> bool {
184 if repos.is_empty() || !pending_needs_workspace(todos) {
185 return false;
186 }
187 let slug = jj_slug(task);
188 !repos.iter().any(|repo| {
189 let path = git_worktree::git_worktree_path(&repo.repo_path, &slug);
190 git_worktree::git_worktree_exists(&path)
191 })
192}
193
194pub fn compute_workflow_phase(
196 vcs_mode: VcsMode,
197 task: &Task,
198 todos: &[Todo],
199 worktrees: &[Worktree],
200 repos: &[TaskRepo],
201) -> WorkflowPhase {
202 if task.status == TaskStatus::Archived {
203 return WorkflowPhase::Archived;
204 }
205
206 if repos.is_empty() {
207 return WorkflowPhase::Setup;
208 }
209
210 let sync_needed = match vcs_mode {
211 VcsMode::Jj => {
212 legacy_worktree_sync_needed(todos, worktrees) || jj_workspace_needed(task, todos, repos)
213 }
214 VcsMode::Git => git_workspace_needed(task, todos, repos),
215 };
216
217 if sync_needed {
218 return WorkflowPhase::SyncRequired;
219 }
220
221 if todos.iter().any(|todo| todo.status == TodoStatus::Pending) {
222 return WorkflowPhase::Execute;
223 }
224
225 WorkflowPhase::TaskComplete
226}
227
228pub fn build_workflow_context(
230 vcs_mode: VcsMode,
231 task: &Task,
232 todos: &[Todo],
233 worktrees: &[Worktree],
234 repos: &[TaskRepo],
235) -> WorkflowContext {
236 let phase = compute_workflow_phase(vcs_mode, task, todos, worktrees, repos);
237 WorkflowContext {
238 next_action: build_next_action(vcs_mode, phase, task, todos, worktrees, repos),
239 checklist: build_workflow_checklist(vcs_mode, phase, task, todos, repos),
240 phase,
241 }
242}
243
244pub fn build_workflow_checklist(
246 vcs_mode: VcsMode,
247 phase: WorkflowPhase,
248 task: &Task,
249 todos: &[Todo],
250 repos: &[TaskRepo],
251) -> Vec<WorkflowStep> {
252 let slug = jj_slug(task);
253 let paths = repo_paths(repos);
254
255 match phase {
256 WorkflowPhase::Setup => {
257 let mut steps = vec![WorkflowStep {
258 id: "repo",
259 label: "Register at least one repository".to_string(),
260 done: !repos.is_empty(),
261 command: Some("track repo add".to_string()),
262 }];
263 if vcs_mode == VcsMode::Jj && !repos.is_empty() {
264 let all_init = repos
265 .iter()
266 .all(|repo| jj_task::repo_initialized(&repo.repo_path));
267 steps.push(WorkflowStep {
268 id: "jj_repo_init",
269 label: "Initialize jj-task in each repo (once)".to_string(),
270 done: all_init,
271 command: Some("jj-task repo init".to_string()),
272 });
273 }
274 steps.push(WorkflowStep {
275 id: "todos",
276 label: "Add TODOs for this task".to_string(),
277 done: !todos.is_empty(),
278 command: Some("track todo add \"...\"".to_string()),
279 });
280 steps
281 }
282 WorkflowPhase::SyncRequired => {
283 let mut steps = Vec::new();
284 if vcs_mode == VcsMode::Jj && !repos.is_empty() {
285 let statuses = jj_task::repos_workspace_status(&slug, &paths);
286 for status in statuses {
287 let label = format!("jj-task start in {}", status.repo_path);
288 steps.push(WorkflowStep {
289 id: "jj_task_start",
290 label,
291 done: status.registered,
292 command: Some(format!("jj-task start {slug}")),
293 });
294 }
295 } else if vcs_mode == VcsMode::Git {
296 steps.push(WorkflowStep {
297 id: "git_sync",
298 label: "Create git worktree for this task".to_string(),
299 done: false,
300 command: Some("track sync".to_string()),
301 });
302 }
303 steps
304 }
305 _ => Vec::new(),
306 }
307}
308
309pub fn build_next_action(
311 vcs_mode: VcsMode,
312 phase: WorkflowPhase,
313 task: &Task,
314 todos: &[Todo],
315 worktrees: &[Worktree],
316 repos: &[TaskRepo],
317) -> NextAction {
318 let slug = jj_slug(task);
319 let paths = repo_paths(repos);
320
321 match phase {
322 WorkflowPhase::Setup => NextAction {
323 kind: NextActionKind::RunCommand,
324 command: Some("track repo add [path]".to_string()),
325 reason: match vcs_mode {
326 VcsMode::Jj => {
327 "Register at least one repository, then run jj-task repo init from the main workspace".to_string()
328 }
329 VcsMode::Git => {
330 "Register at least one git repository, then run track sync to create a worktree".to_string()
331 }
332 },
333 },
334 WorkflowPhase::SyncRequired => match vcs_mode {
335 VcsMode::Jj => {
336 if legacy_worktree_sync_needed(todos, worktrees) {
337 NextAction {
338 kind: NextActionKind::RunCommand,
339 command: Some("track sync".to_string()),
340 reason: "Legacy per-TODO --worktree workspaces are pending (prefer jj-task for new tasks)".to_string(),
341 }
342 } else {
343 let missing = jj_task::unregistered_repo_paths(&slug, &paths);
344 let reason = if missing.len() > 1 {
345 format!(
346 "Start jj-task workspace in each repo ({}/{} ready). Run jj-task repo init once from each main workspace if needed.",
347 paths.len() - missing.len(),
348 paths.len()
349 )
350 } else {
351 format!(
352 "Start a jj-task workspace at .worktrees/{slug}. Run jj-task repo init once from the main repo if needed. Load the $jj skill for commits and PR."
353 )
354 };
355 NextAction {
356 kind: NextActionKind::RunCommand,
357 command: Some(format!("jj-task start {slug}")),
358 reason,
359 }
360 }
361 }
362 VcsMode::Git => NextAction {
363 kind: NextActionKind::RunCommand,
364 command: Some("track sync".to_string()),
365 reason: format!(
366 "Create git worktree at .worktrees/{slug} on branch track/{slug}"
367 ),
368 },
369 },
370 WorkflowPhase::Execute => {
371 let next_todo = oldest_pending_todo(todos);
372 if let Some(todo) = next_todo {
373 match vcs_mode {
374 VcsMode::Jj => {
375 if todo.requires_workspace {
376 if jj_task::all_repos_registered(&slug, &paths) {
377 return NextAction {
378 kind: NextActionKind::RunCommand,
379 command: Some(format!("cd \"$(jj-task path {slug})\"")),
380 reason: format!(
381 "Work on TODO #{} in jj-task workspace. Use $jj skill for jj commit/squash/push — not jj describe alone.",
382 todo.task_index
383 ),
384 };
385 }
386
387 return NextAction {
388 kind: NextActionKind::RunCommand,
389 command: Some(format!("jj-task start {slug}")),
390 reason: format!(
391 "Workspace required for TODO #{} — run jj-task start first",
392 todo.task_index
393 ),
394 };
395 }
396
397 if jj_task::slug_registered(&slug, &paths) {
398 return NextAction {
399 kind: NextActionKind::RunCommand,
400 command: Some(format!("cd \"$(jj-task path {slug})\"")),
401 reason: format!(
402 "Optional: work in jj-task workspace for TODO #{}",
403 todo.task_index
404 ),
405 };
406 }
407
408 let has_workspace = worktrees.iter().any(|wt| wt.todo_id == Some(todo.id));
409 if todo.worktree_requested && has_workspace {
410 return NextAction {
411 kind: NextActionKind::RunCommand,
412 command: Some(format!("track todo workspace {}", todo.task_index)),
413 reason: format!(
414 "Legacy TODO workspace for #{}: {}",
415 todo.task_index, todo.content
416 ),
417 };
418 }
419 }
420 VcsMode::Git => {
421 if todo.requires_workspace {
422 if let Some(repo) = repos.first() {
423 let worktree_path =
424 git_worktree::git_worktree_path(&repo.repo_path, &slug);
425 if git_worktree::git_worktree_exists(&worktree_path) {
426 return NextAction {
427 kind: NextActionKind::RunCommand,
428 command: Some(format!("cd \"{worktree_path}\"")),
429 reason: format!(
430 "Work on TODO #{} in git worktree. Commit and push with standard git commands.",
431 todo.task_index
432 ),
433 };
434 }
435 }
436 return NextAction {
437 kind: NextActionKind::RunCommand,
438 command: Some("track sync".to_string()),
439 reason: format!(
440 "Git worktree required for TODO #{} — run track sync first",
441 todo.task_index
442 ),
443 };
444 }
445 if let Some(repo) = repos.first() {
446 let worktree_path =
447 git_worktree::git_worktree_path(&repo.repo_path, &slug);
448 if git_worktree::git_worktree_exists(&worktree_path) {
449 return NextAction {
450 kind: NextActionKind::RunCommand,
451 command: Some(format!("cd \"{worktree_path}\"")),
452 reason: format!(
453 "Work on TODO #{} in git worktree. Commit and push with standard git commands.",
454 todo.task_index
455 ),
456 };
457 }
458 }
459 }
460 }
461
462 NextAction {
463 kind: NextActionKind::ExecuteTodo,
464 command: Some(format!("track todo done {}", todo.task_index)),
465 reason: format!("Continue with TODO #{}: {}", todo.task_index, todo.content),
466 }
467 } else {
468 NextAction {
469 kind: NextActionKind::WaitHuman,
470 command: None,
471 reason: "No pending TODOs found".to_string(),
472 }
473 }
474 }
475 WorkflowPhase::TaskComplete => match vcs_mode {
476 VcsMode::Jj => {
477 let had_workspace_todos = todos.iter().any(|t| t.requires_workspace);
478 let registered = jj_task::all_repos_registered(&slug, &paths);
479 let phase = jj_task::task_phase(&slug, &paths);
480 if had_workspace_todos && registered && phase.as_deref() != Some("done") {
481 NextAction {
482 kind: NextActionKind::UseJjSkill,
483 command: None,
484 reason: format!(
485 "All TODOs done — use $jj skill to push/merge PR, then `jj-task done {slug}` and `track archive`"
486 ),
487 }
488 } else {
489 NextAction {
490 kind: NextActionKind::RunCommand,
491 command: Some(format!("jj-task done {slug}; track archive")),
492 reason: "All TODOs done — use $jj skill to push/merge PR, then jj-task done and track archive".to_string(),
493 }
494 }
495 }
496 VcsMode::Git => NextAction {
497 kind: NextActionKind::RunCommand,
498 command: Some("track archive".to_string()),
499 reason: "All TODOs done — push/merge your PR with git, then archive the task".to_string(),
500 },
501 },
502 WorkflowPhase::Archived => NextAction {
503 kind: NextActionKind::WaitHuman,
504 command: None,
505 reason: "Task is archived".to_string(),
506 },
507 }
508}
509
510pub fn build_jj_context(task: &Task, repos: &[TaskRepo]) -> JjAgentContext {
511 let slug = jj_slug(task);
512 let paths = repo_paths(repos);
513 let repo_statuses = jj_task::repos_workspace_status(&slug, &paths);
514 let workspace_registered = jj_task::all_repos_registered(&slug, &paths);
515 let task_phase = jj_task::task_phase(&slug, &paths);
516 let workspace_path = if workspace_registered {
517 repo_statuses
518 .iter()
519 .find_map(|status| status.workspace_path.clone())
520 } else {
521 paths
522 .first()
523 .map(|first_repo| jj_task::expected_workspace_path(first_repo, &slug))
524 };
525
526 JjAgentContext {
527 slug: slug.clone(),
528 skill: "jj",
529 workspace_registered,
530 workspace_path,
531 task_phase,
532 repos: repo_statuses,
533 start_command: format!("jj-task start {slug}"),
534 path_command: format!("jj-task path {slug}"),
535 repo_init_command: "jj-task repo init",
536 }
537}
538
539pub fn build_git_context(task: &Task, repos: &[TaskRepo]) -> GitAgentContext {
540 let slug = jj_slug(task);
541 let branch = git_worktree::git_branch_name(&slug);
542 let workspace_path = repos
543 .first()
544 .map(|repo| git_worktree::git_worktree_path(&repo.repo_path, &slug));
545 let workspace_ready = workspace_path
546 .as_deref()
547 .is_some_and(git_worktree::git_worktree_exists);
548
549 GitAgentContext {
550 slug,
551 branch,
552 workspace_ready,
553 workspace_path,
554 sync_command: "track sync".to_string(),
555 }
556}
557
558pub fn oldest_pending_todo(todos: &[Todo]) -> Option<&Todo> {
559 todos
560 .iter()
561 .filter(|todo| todo.status == TodoStatus::Pending)
562 .min_by_key(|todo| todo.task_index)
563}
564
565pub fn workspace_lifecycle(todo: &Todo, worktrees: &[Worktree]) -> WorkspaceLifecycle {
566 if !todo.worktree_requested {
567 return WorkspaceLifecycle::NotRequested;
568 }
569
570 if todo.status == TodoStatus::Done {
571 return WorkspaceLifecycle::Merged;
572 }
573
574 if worktrees.iter().any(|wt| wt.todo_id == Some(todo.id)) {
575 WorkspaceLifecycle::Ready
576 } else {
577 WorkspaceLifecycle::Requested
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use chrono::Utc;
585
586 fn sample_task(status: TaskStatus) -> Task {
587 Task {
588 id: 1,
589 name: "Task".to_string(),
590 description: None,
591 status,
592 ticket_id: Some("PROJ-1".to_string()),
593 ticket_url: None,
594 alias: None,
595 is_today_task: false,
596 created_at: Utc::now(),
597 }
598 }
599
600 fn sample_todo(index: i64, worktree_requested: bool) -> Todo {
601 Todo {
602 id: index,
603 task_id: 1,
604 task_index: index,
605 content: format!("Todo {}", index),
606 status: TodoStatus::Pending,
607 worktree_requested,
608 requires_workspace: true,
609 created_at: Utc::now(),
610 completed_at: None,
611 }
612 }
613
614 fn sample_research_todo(index: i64) -> Todo {
615 Todo {
616 id: index,
617 task_id: 1,
618 task_index: index,
619 content: format!("Research {}", index),
620 status: TodoStatus::Pending,
621 worktree_requested: false,
622 requires_workspace: false,
623 created_at: Utc::now(),
624 completed_at: None,
625 }
626 }
627
628 fn sample_repo() -> TaskRepo {
629 TaskRepo {
630 id: 1,
631 task_id: 1,
632 task_index: 1,
633 repo_path: "/repo".to_string(),
634 base_branch: None,
635 base_commit_hash: None,
636 created_at: Utc::now(),
637 }
638 }
639
640 #[test]
641 fn workflow_phase_detects_legacy_sync_required() {
642 let task = sample_task(TaskStatus::Active);
643 let todos = vec![sample_todo(1, true)];
644 let repos = vec![sample_repo()];
645
646 assert_eq!(
647 compute_workflow_phase(VcsMode::Jj, &task, &todos, &[], &repos),
648 WorkflowPhase::SyncRequired
649 );
650 }
651
652 #[test]
653 fn workflow_phase_jj_needs_workspace_without_map() {
654 let task = sample_task(TaskStatus::Active);
655 let todos = vec![sample_todo(1, false)];
656 let repos = vec![sample_repo()];
657
658 assert_eq!(
659 compute_workflow_phase(VcsMode::Jj, &task, &todos, &[], &repos),
660 WorkflowPhase::SyncRequired
661 );
662 }
663
664 #[test]
665 fn workflow_phase_git_needs_sync_without_worktree() {
666 let task = sample_task(TaskStatus::Active);
667 let todos = vec![sample_todo(1, false)];
668 let repos = vec![sample_repo()];
669
670 assert_eq!(
671 compute_workflow_phase(VcsMode::Git, &task, &todos, &[], &repos),
672 WorkflowPhase::SyncRequired
673 );
674 }
675
676 #[test]
677 fn sync_required_action_prefers_jj_task_start() {
678 let task = sample_task(TaskStatus::Active);
679 let todos = vec![sample_todo(1, false)];
680 let repos = vec![sample_repo()];
681
682 let action = build_next_action(
683 VcsMode::Jj,
684 WorkflowPhase::SyncRequired,
685 &task,
686 &todos,
687 &[],
688 &repos,
689 );
690 assert_eq!(action.command.as_deref(), Some("jj-task start proj-1"));
691 }
692
693 #[test]
694 fn sync_required_action_uses_track_sync_in_git_mode() {
695 let task = sample_task(TaskStatus::Active);
696 let todos = vec![sample_todo(1, false)];
697 let repos = vec![sample_repo()];
698
699 let action = build_next_action(
700 VcsMode::Git,
701 WorkflowPhase::SyncRequired,
702 &task,
703 &todos,
704 &[],
705 &repos,
706 );
707 assert_eq!(action.command.as_deref(), Some("track sync"));
708 }
709
710 #[test]
711 fn build_jj_context_includes_slug_and_commands() {
712 let task = sample_task(TaskStatus::Active);
713 let ctx = build_jj_context(&task, &[]);
714 assert_eq!(ctx.slug, "proj-1");
715 assert_eq!(ctx.skill, "jj");
716 assert_eq!(ctx.start_command, "jj-task start proj-1");
717 }
718
719 #[test]
720 fn build_git_context_includes_branch_and_sync() {
721 let task = sample_task(TaskStatus::Active);
722 let repos = vec![sample_repo()];
723 let ctx = build_git_context(&task, &repos);
724 assert_eq!(ctx.slug, "proj-1");
725 assert_eq!(ctx.branch, "track/proj-1");
726 assert_eq!(ctx.sync_command, "track sync");
727 assert!(!ctx.workspace_ready);
728 }
729
730 #[test]
731 fn research_todo_skips_sync_required_without_workspace() {
732 let task = sample_task(TaskStatus::Active);
733 let todos = vec![sample_research_todo(1)];
734 let repos = vec![sample_repo()];
735
736 assert_eq!(
737 compute_workflow_phase(VcsMode::Jj, &task, &todos, &[], &repos),
738 WorkflowPhase::Execute
739 );
740 }
741
742 #[test]
743 fn guardrails_disable_jj_skill_in_git_mode() {
744 let guardrails = AgentGuardrails::for_mode(VcsMode::Git, false);
745 assert!(!guardrails.must_use_jj_skill);
746 assert!(guardrails.reopen_forbidden);
747 }
748
749 #[test]
750 fn research_todo_without_workspace_skips_sync_in_jj_mode() {
751 let task = sample_task(TaskStatus::Active);
752 let todos = vec![sample_research_todo(1)];
753 let repos = vec![sample_repo()];
754
755 assert_eq!(
756 compute_workflow_phase(VcsMode::Jj, &task, &todos, &[], &repos),
757 WorkflowPhase::Execute
758 );
759 }
760
761 #[test]
762 fn execute_action_for_no_workspace_todo_suggests_done() {
763 let task = sample_task(TaskStatus::Active);
764 let todos = vec![sample_research_todo(1)];
765 let repos = vec![sample_repo()];
766
767 let action = build_next_action(
768 VcsMode::Jj,
769 WorkflowPhase::Execute,
770 &task,
771 &todos,
772 &[],
773 &repos,
774 );
775 assert_eq!(action.kind, NextActionKind::ExecuteTodo);
776 assert_eq!(action.command.as_deref(), Some("track todo done 1"));
777 }
778
779 #[test]
780 fn next_action_kind_serializes_as_snake_case() {
781 let action = NextAction {
782 kind: NextActionKind::UseJjSkill,
783 command: None,
784 reason: "test".to_string(),
785 };
786 let json = serde_json::to_value(action).unwrap();
787 assert_eq!(json["kind"], "use_jj_skill");
788 }
789}