Files
ClaudeDo/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
T
mika kuns 9c4a63d2c5 fix(worker): thread handler task id into list-handler kickoff prompts
Neither the initial nor the handoff kickoff ever told a list-handler
session its own handler task id, so handoff_list_handler(taskId, ...)
was unrenderable -- the handoff chain broke exactly where it was needed
(#200/#201 on 2026-08-21).

Add {handlerTaskId} to both MergeHelperInitialDefault and
MergeHelperHandoffDefault, thread a handlerTaskId parameter through
BuildForMergeHelperAsync (interface, WorkerHub.GetMergeHelperLaunchSpec,
IWorkerClient/WorkerClient, and the MissionControlViewModel call site,
which already had the id from CreateMergeHelperTaskAsync but never
passed it on), and render it in BuildForMergeHelperHandoffAsync from the
taskId parameter it already receives.

RenderTemplate leaves unknown/missing tokens untouched, so a
user-edited override without the new token still renders fine -- no
forced migration for override users.
2026-08-21 18:00:16 +02:00

433 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 DefaultFor_merge_helper_initial_has_handler_task_id_token()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
Assert.Contains("{handlerTaskId}", 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_has_handler_task_id_token()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperHandoff);
Assert.Contains("{handlerTaskId}", 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);
}
}