diff --git a/src/ClaudeDo.Data/TaskPromptComposer.cs b/src/ClaudeDo.Data/TaskPromptComposer.cs new file mode 100644 index 0000000..5ca3a82 --- /dev/null +++ b/src/ClaudeDo.Data/TaskPromptComposer.cs @@ -0,0 +1,29 @@ +using System.Text; + +namespace ClaudeDo.Data; + +/// +/// Single source of truth for the text handed to Claude as a task prompt: +/// title + description + the OPEN sub-tasks. Resolved sub-tasks are dropped. +/// Shared by the Worker (real prompt) and the UI (the card's "what Claude gets" preview). +/// +public static class TaskPromptComposer +{ + public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks) + { + var sb = new StringBuilder((title ?? "").Trim()); + + if (!string.IsNullOrWhiteSpace(description)) + sb.Append("\n\n").Append(description.Trim()); + + var open = subtasks?.Where(s => !s.Completed).ToList() ?? new List<(string, bool)>(); + if (open.Count > 0) + { + sb.Append("\n\n## Sub-Tasks\n"); + foreach (var s in open) + sb.Append("- [ ] ").Append(s.Title).Append('\n'); + } + + return sb.ToString(); + } +} diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 53fda84..816d8b4 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -101,16 +101,10 @@ public sealed class TaskRunner await _state.StartRunningAsync(task.Id, now, ct); await _broadcaster.TaskStarted(slot, task.Id, now); - // Build prompt. - var sb = new System.Text.StringBuilder(task.Title); - if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim()); - if (subtasks.Count > 0) - { - sb.Append("\n\n## Sub-Tasks\n"); - foreach (var s in subtasks) - sb.Append(s.Completed ? "- [x] " : "- [ ] ").Append(s.Title).Append('\n'); - } - var prompt = sb.ToString(); + // Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped). + var prompt = TaskPromptComposer.Compose( + task.Title, task.Description, + subtasks.Select(s => (s.Title, s.Completed))); // Run 1. var result = await RunOnceAsync(task.Id, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct); diff --git a/tests/ClaudeDo.Data.Tests/TaskPromptComposerTests.cs b/tests/ClaudeDo.Data.Tests/TaskPromptComposerTests.cs new file mode 100644 index 0000000..17279b3 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/TaskPromptComposerTests.cs @@ -0,0 +1,45 @@ +using ClaudeDo.Data; +using Xunit; + +namespace ClaudeDo.Data.Tests; + +public class TaskPromptComposerTests +{ + [Fact] + public void Composes_title_description_and_open_steps() + { + var result = TaskPromptComposer.Compose( + "Refactor diff viewer", + "Share the row template.", + new (string, bool)[] { ("Done step", true), ("Open step", false) }); + + Assert.Equal( + "Refactor diff viewer\n\nShare the row template.\n\n## Sub-Tasks\n- [ ] Open step\n", + result); + } + + [Fact] + public void Drops_resolved_steps_and_omits_section_when_none_open() + { + var result = TaskPromptComposer.Compose( + "Title", + "Desc", + new (string, bool)[] { ("a", true), ("b", true) }); + + Assert.Equal("Title\n\nDesc", result); + } + + [Fact] + public void Omits_description_when_blank() + { + var result = TaskPromptComposer.Compose("Title", " ", new (string, bool)[] { ("open", false) }); + + Assert.Equal("Title\n\n## Sub-Tasks\n- [ ] open\n", result); + } + + [Fact] + public void Title_only_when_no_description_or_steps() + { + Assert.Equal("Just a title", TaskPromptComposer.Compose("Just a title", null, System.Array.Empty<(string, bool)>())); + } +}