Adds HandlerTriageAlias/HandlerWaitAlias/HandlerMergeAlias to ModelRegistry and replaces PromptKind.MergeHelperExecute with MergeHelperWait (phase 3 only) and MergeHelperMerge (phases 4-5), so the list handler can run as three cost-scoped sessions instead of two. Triage's Phase 2 now checks/sets a wide verify command via get_list_config/set_list_config once per run. Merge now merges before reruns, delegates diff review to a sonnet subagent, rejects 0-file diffs, tracks the merged-but-not-Done verify-gate outcome, and caps reruns at one per task via handoff_list_handler's new nextPhase parameter. InteractiveLaunchSpecService.cs still references the removed PromptKind.MergeHelperExecute and fails to build -- wiring the Worker/UI side onto the new roles is a follow-up task.
419 lines
17 KiB
C#
419 lines
17 KiB
C#
using ClaudeDo.Data;
|
|
|
|
namespace ClaudeDo.Data.Tests;
|
|
|
|
public class PromptFilesTests
|
|
{
|
|
[Fact]
|
|
public void RenderTemplate_replaces_known_tokens()
|
|
{
|
|
var outp = PromptFiles.RenderTemplate(
|
|
"Plan for {date}, cap {maxTasks}.",
|
|
new Dictionary<string, string> { ["date"] = "2026-06-04", ["maxTasks"] = "5" });
|
|
Assert.Equal("Plan for 2026-06-04, cap 5.", outp);
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderTemplate_leaves_unknown_braces_intact()
|
|
{
|
|
var outp = PromptFiles.RenderTemplate(
|
|
"## {Wochentag}, {dd.MM.yyyy} — {start}",
|
|
new Dictionary<string, string> { ["start"] = "01.06.2026" });
|
|
Assert.Equal("## {Wochentag}, {dd.MM.yyyy} — 01.06.2026", outp);
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderTemplate_does_not_substitute_tokens_that_appear_inside_a_value()
|
|
{
|
|
// A sharpened task description can legitimately contain "{repo}" (a config snippet, a
|
|
// path placeholder). Substitution must be a single pass over the TEMPLATE, so injected
|
|
// values are never rescanned.
|
|
var outp = PromptFiles.RenderTemplate(
|
|
"Repo: {repo}\n\n{tasks}",
|
|
new Dictionary<string, string>
|
|
{
|
|
["repo"] = "C:\\real\\repo",
|
|
["tasks"] = "- Fix the {repo} placeholder in the config template",
|
|
});
|
|
|
|
Assert.Equal("Repo: C:\\real\\repo\n\n- Fix the {repo} placeholder in the config template", outp);
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderTemplate_result_is_independent_of_dictionary_order()
|
|
{
|
|
const string template = "Scope: {scope}\nRepo: {repo}\n\n{tasks}";
|
|
var task = "- Document {scope} and {repo} tokens";
|
|
|
|
var tasksLast = PromptFiles.RenderTemplate(template, new Dictionary<string, string>
|
|
{
|
|
["scope"] = "List: Bugs", ["repo"] = "C:\\repo", ["tasks"] = task,
|
|
});
|
|
var tasksFirst = PromptFiles.RenderTemplate(template, new Dictionary<string, string>
|
|
{
|
|
["tasks"] = task, ["repo"] = "C:\\repo", ["scope"] = "List: Bugs",
|
|
});
|
|
|
|
Assert.Equal(tasksLast, tasksFirst);
|
|
Assert.Contains("- Document {scope} and {repo} tokens", tasksLast);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_system_mentions_blocked_marker_and_scope()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.System);
|
|
Assert.Contains("CLAUDEDO_BLOCKED:", d);
|
|
Assert.Contains("unattended", d, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_planning_initial_has_title_and_description_tokens()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.PlanningInitial);
|
|
Assert.Contains("{title}", d);
|
|
Assert.Contains("{description}", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void PathFor_planning_is_planning_system_file()
|
|
{
|
|
Assert.EndsWith("planning-system.md", PromptFiles.PathFor(PromptKind.Planning));
|
|
}
|
|
|
|
[Fact]
|
|
public void PathFor_merge_helper_kinds_map_to_their_files()
|
|
{
|
|
Assert.EndsWith("merge-helper-triage.md", PromptFiles.PathFor(PromptKind.MergeHelperTriage));
|
|
Assert.EndsWith("merge-helper-wait.md", PromptFiles.PathFor(PromptKind.MergeHelperWait));
|
|
Assert.EndsWith("merge-helper-merge.md", PromptFiles.PathFor(PromptKind.MergeHelperMerge));
|
|
Assert.EndsWith("merge-helper-initial.md", PromptFiles.PathFor(PromptKind.MergeHelperInitial));
|
|
Assert.EndsWith("merge-helper-handoff.md", PromptFiles.PathFor(PromptKind.MergeHelperHandoff));
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_triage_covers_phases_0_to_2_only()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
|
|
Assert.False(string.IsNullOrWhiteSpace(d));
|
|
Assert.Contains("## Phase 0", d);
|
|
Assert.Contains("## Phase 1", d);
|
|
Assert.Contains("## Phase 2", d);
|
|
// The run/review/merge phases belong to the handoff session's prompt, not this one —
|
|
// carrying them here is what made the handoff session redo dedupe work.
|
|
Assert.DoesNotContain("## Phase 3", d);
|
|
Assert.DoesNotContain("## Phase 4", d);
|
|
Assert.DoesNotContain("## Phase 5", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_wait_covers_phase_3_only()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperWait);
|
|
Assert.False(string.IsNullOrWhiteSpace(d));
|
|
Assert.Contains("## Phase 3", d);
|
|
Assert.DoesNotContain("## Phase 0", d);
|
|
Assert.DoesNotContain("## Phase 1", d);
|
|
Assert.DoesNotContain("## Phase 2", d);
|
|
Assert.DoesNotContain("## Phase 4", d);
|
|
Assert.DoesNotContain("## Phase 5", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_merge_covers_phases_4_to_5_only()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge);
|
|
Assert.False(string.IsNullOrWhiteSpace(d));
|
|
Assert.Contains("## Phase 4", d);
|
|
Assert.Contains("## Phase 5", d);
|
|
Assert.DoesNotContain("## Phase 0", d);
|
|
Assert.DoesNotContain("## Phase 1", d);
|
|
Assert.DoesNotContain("## Phase 2", d);
|
|
Assert.DoesNotContain("## Phase 3", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_triage_names_the_tools_its_phases_need()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
|
|
Assert.Contains("batch_get_tasks", d); // phase 0
|
|
Assert.Contains("update_task", d); // phase 1 + 2
|
|
Assert.Contains("get_list_config", d); // phase 2 verify-command check
|
|
Assert.Contains("set_list_config", d); // phase 2 verify-command check
|
|
Assert.Contains("handoff_list_handler", d); // handoff
|
|
// Triage never runs or merges anything.
|
|
Assert.DoesNotContain("review_task", d);
|
|
Assert.DoesNotContain("continue_merge", d);
|
|
Assert.DoesNotContain("preview_merge_set", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_wait_names_the_tools_it_needs()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperWait);
|
|
Assert.Contains("update_task_status", d);
|
|
Assert.Contains("wait_for_task_change", d);
|
|
Assert.Contains("handoff_list_handler", d);
|
|
// Wait never touches diffs or merges.
|
|
Assert.DoesNotContain("get_task_diff", d);
|
|
Assert.DoesNotContain("review_task", d);
|
|
Assert.DoesNotContain("continue_merge", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_merge_names_the_tools_it_needs()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge);
|
|
Assert.Contains("batch_get_tasks", d);
|
|
Assert.Contains("preview_merge_set", d);
|
|
Assert.Contains("review_task", d);
|
|
Assert.Contains("continue_merge", d);
|
|
Assert.Contains("terminal_reason", d);
|
|
Assert.Contains("continue_task", d);
|
|
Assert.Contains("reset_failed_task", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_wait_waits_with_the_real_server_side_cap()
|
|
{
|
|
// TaskWaitMcpTools.MaxTimeoutSeconds is 900 and every ClaudeDo launcher sets
|
|
// MCP_TOOL_TIMEOUT=930000ms. The prompt used to say 170 -- a leftover from the retired
|
|
// 200000ms era -- which burned ~5x the turns on re-waiting.
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperWait);
|
|
Assert.Contains("timeoutSeconds 900", d);
|
|
Assert.DoesNotContain("170", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_wait_waits_through_waiting_for_children()
|
|
{
|
|
// Without the flag, a parent with children reports "changed" as soon as it reaches
|
|
// WaitingForChildren, so the handler would advance to merge while children run.
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperWait);
|
|
Assert.Contains("treatWaitingForChildrenAsBusy=true", d);
|
|
Assert.Contains("WaitingForChildren", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_wait_never_touches_diffs_or_merge_state()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperWait);
|
|
Assert.Contains("Touch nothing else", d);
|
|
Assert.Contains("do not read a diff, merge, reset, restart, or cancel any task", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_merge_keeps_the_shared_checkout_git_guard()
|
|
{
|
|
// The handler session never gets PromptKind.System, so this is the ONLY place the
|
|
// never-`git add -A`-in-a-shared-checkout rule reaches a list-handler run.
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge);
|
|
Assert.Contains("git add -- <the resolved paths>", d);
|
|
Assert.Contains("NEVER `git add -A`", d);
|
|
Assert.Contains("Never use raw `git merge`", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_merge_runs_all_merges_before_any_rerun()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge);
|
|
Assert.Contains("BEFORE starting any rerun", d);
|
|
Assert.Contains("only after every merge above is done", d, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_merge_never_reads_diffs_itself()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge);
|
|
Assert.Contains("Never read a task's diff yourself", d);
|
|
Assert.Contains("sonnet subagent", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_merge_rejects_empty_diffs_before_approving()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge);
|
|
Assert.Contains("changedFileCount", d);
|
|
Assert.Contains("0 changed files means an empty branch", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_merge_caps_reruns_at_one_and_hands_off_to_wait_final()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge);
|
|
Assert.Contains("at most ONE rerun per task", d, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("nextPhase: \"wait_final\"", d);
|
|
Assert.Contains("merge_final", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_merge_holds_back_done_on_a_failed_verify_gate()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge);
|
|
Assert.Contains("merged-but-not-Done", d);
|
|
Assert.Contains("do NOT try to fix the verify failure yourself", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_only_asks_dedupe_questions_when_a_candidate_exists()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
|
|
Assert.Contains("move straight to Phase 2", d);
|
|
Assert.Contains("do not ask the user to confirm the absence of duplicates", d);
|
|
Assert.Contains("Cancel nothing without an explicit answer", d);
|
|
Assert.DoesNotContain("whenever you are unsure", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_phase0_treats_brief_as_primary_source()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
|
|
Assert.Contains("primary source", d, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("batch_get_tasks", d);
|
|
Assert.Contains("Do not act on any single task before you have read", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_allows_splitting_bundled_tasks_in_phase_2()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
|
|
Assert.Contains("Propose splitting it to the user", d);
|
|
Assert.Contains("add_task/add_subtask", d);
|
|
Assert.Contains("do not invent requirements", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_triage_checks_verify_command_once_per_run()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
|
|
Assert.Contains("get_list_config", d);
|
|
Assert.Contains("ONCE for this whole run", d);
|
|
Assert.Contains("set_list_config(verifyCommand:", d);
|
|
// Never overwrite an operator-set value.
|
|
Assert.Contains("never overwrite an existing one", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_triage_demands_a_wide_verify_command_proven_by_running_it()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
|
|
Assert.Contains("full build plus the complete test suite", d);
|
|
Assert.Contains("Run that command yourself once via Bash", d);
|
|
Assert.Contains("ask the user to confirm it", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_system_scopes_suggest_improvement_to_top_level_tasks()
|
|
{
|
|
// TaskRunner only allowlists SuggestImprovement when ParentTaskId is null AND
|
|
// PlanningPhase is None, and TaskRunMcpService throws for any child -- but this
|
|
// prompt reaches every run, including planning children.
|
|
var d = PromptFiles.DefaultFor(PromptKind.System);
|
|
Assert.Contains("SuggestImprovement", d);
|
|
Assert.Contains("standalone top-level task", d);
|
|
Assert.Contains("improvements are one layer deep", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_planning_names_every_allowlisted_tool_and_claims_no_exclusivity()
|
|
{
|
|
// WindowsTerminalLauncher allowlists mcp__claudedo__*,Read,Grep,Glob,WebFetch,
|
|
// WebSearch,Skill. The prompt used to say "Use nothing else" right after telling the
|
|
// session to invoke the brainstorming skill -- forbidding its own first instruction.
|
|
var d = PromptFiles.DefaultFor(PromptKind.Planning);
|
|
Assert.DoesNotContain("Use nothing else", d);
|
|
Assert.Contains("Skill", d);
|
|
Assert.Contains("Grep", d);
|
|
Assert.Contains("WebSearch", d);
|
|
Assert.Contains("Write, Edit or Bash", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_refine_gates_the_read_tools_on_an_available_repo()
|
|
{
|
|
// RefinePrompt.BuildArgs only appends Read/Grep/Glob when canReadRepo is true.
|
|
var d = PromptFiles.DefaultFor(PromptKind.Refine);
|
|
Assert.Contains("only when a repository is available", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_checks_file_collisions_before_merge_order()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge);
|
|
Assert.Contains("Default to the order the brief lists them", d);
|
|
Assert.Contains("tell the user which tasks collide", d);
|
|
Assert.Contains("NORMAL case, not a failure", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_initial_has_repo_token()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
|
|
Assert.Contains("{repo}", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_initial_has_scope_and_tasks_tokens()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
|
|
Assert.False(string.IsNullOrWhiteSpace(d));
|
|
Assert.Contains("{scope}", d);
|
|
Assert.Contains("{tasks}", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderTemplate_merge_helper_initial_substitutes_scope_and_tasks()
|
|
{
|
|
var outp = PromptFiles.RenderTemplate(
|
|
PromptFiles.DefaultFor(PromptKind.MergeHelperInitial),
|
|
new Dictionary<string, string> { ["scope"] = "All lists", ["tasks"] = "- [Idle] T1" });
|
|
Assert.Contains("Scope: All lists", outp);
|
|
Assert.Contains("- [Idle] T1", outp);
|
|
Assert.DoesNotContain("{scope}", outp);
|
|
Assert.DoesNotContain("{tasks}", outp);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_handoff_has_scope_repo_and_tasks_tokens()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperHandoff);
|
|
Assert.False(string.IsNullOrWhiteSpace(d));
|
|
Assert.Contains("{scope}", d);
|
|
Assert.Contains("{repo}", d);
|
|
Assert.Contains("{tasks}", d);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_handoff_points_at_phase_3()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperHandoff);
|
|
Assert.Contains("phase 3", d, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderTemplate_merge_helper_handoff_substitutes_scope_repo_and_tasks()
|
|
{
|
|
var outp = PromptFiles.RenderTemplate(
|
|
PromptFiles.DefaultFor(PromptKind.MergeHelperHandoff),
|
|
new Dictionary<string, string>
|
|
{
|
|
["scope"] = "List: Bugs",
|
|
["repo"] = "C:\\repo",
|
|
["tasks"] = "- [WaitingForReview] T1 (id: abc)",
|
|
});
|
|
Assert.Contains("Scope: List: Bugs", outp);
|
|
Assert.Contains("Repo: C:\\repo", outp);
|
|
Assert.Contains("- [WaitingForReview] T1 (id: abc)", outp);
|
|
Assert.DoesNotContain("{scope}", outp);
|
|
Assert.DoesNotContain("{repo}", outp);
|
|
Assert.DoesNotContain("{tasks}", outp);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultFor_merge_helper_tells_the_session_to_hand_off_after_phase_2()
|
|
{
|
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
|
|
Assert.Contains("handoff_list_handler", d);
|
|
Assert.Contains("do not continue into phase 3 yourself", d, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|