Skip to main content

vtcode_core/prompts/
guidelines.rs

1use std::collections::BTreeSet;
2use std::fmt::Write as _;
3
4use crate::config::constants::tools;
5use crate::config::types::{CapabilityLevel, ResolvedShellPromptProfile};
6use crate::core::agent::harness_kernel::SessionToolCatalogSnapshot;
7use crate::llm::provider::ToolDefinition;
8use crate::prompts::sections::SectionBoundaryMode;
9use crate::tools::registry::tool_groups;
10
11const TOOL_EXEC_COMMAND: &str = tools::EXEC_COMMAND;
12const TOOL_WRITE_STDIN: &str = tools::WRITE_STDIN;
13const TOOL_CODE_SEARCH: &str = tools::CODE_SEARCH;
14const TOOL_READ_FILE: &str = tools::READ_FILE;
15const TOOL_LIST_FILES: &str = tools::LIST_FILES;
16const TOOL_GREP_FILE: &str = tools::GREP_FILE;
17const TOOL_APPLY_PATCH: &str = tools::APPLY_PATCH;
18const TOOL_REQUEST_USER_INPUT: &str = tools::REQUEST_USER_INPUT;
19const TOOL_TASK_TRACKER: &str = tools::TASK_TRACKER;
20const PUBLIC_DECISION_GUIDANCE: &str = "- Use optional `record_decision` for consequential choices, rejected approaches, or recovery changes. Give concise public rationale; ordinary reads and commands need no record.";
21const MATRIX_GUIDANCE: &str = "- `matrix`: create persists explicit tasks, checks, resources, and timeouts; start freezes the spec. The coordinator delegates execution and verification to scheduler-owned workers; discovery is idle-only. Replay restores admission before discovery; resume reconciles owned cleanup. status is a projection; pause stops dispatch; user cancel is terminal. Internal failures require reconciliation, not cancellation. Workers retain effective permissions and sandbox gates. Read leases require read-only workers. Report identity is runtime-owned; final success needs durable declared checks for the current generation, never summaries or child completion. Source and declared input changes, including submodule state, invalidate checks. Automatic retry requires replay_safe, confirmed cleanup, and at most one interrupted/timeout retry; other failures need a coordinator decision.";
22const TOOL_START_PLANNING: &str = tools::START_PLANNING;
23
24const OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE: &str = "- `verify: [skip Markdown lint if unavailable]`: report skipped; review diff/links without installing tools. Lint errors remain failures.";
25
26/// Shared cross-turn resume pointer (invariant #22). The hint body itself stays
27/// transient via `append_transient_turn_notes`; tool guidance only advertises
28/// that a turn-start `Exec session resume:` note carries the live ids so a
29/// resumed or compacted session needs zero identity reconstruction.
30const CROSS_TURN_RESUME_HINT_CLAUSE: &str =
31    "; a turn-start `Exec session resume:` hint carries the live ids when a prior turn ended mid-run.";
32
33/// The single home of `start_planning` guidance, shared by the Minimal and
34/// Default Active Tools. The tool itself asks the user before entering
35/// planning (`require_confirmation`), except under full-auto or
36/// skip-confirmations, so the model calls it rather than proposing it in prose.
37const START_PLANNING_GUIDANCE_LINE: &str = "- For demanding, ambiguous, or multi-phase tasks, call `start_planning`; it asks the user before entering the read-only Planning workflow unless the session runs in full-auto or skips confirmations. Skip it for straightforward changes.";
38
39/// Planning-workflow `task_tracker` index rules. While planning, the tracker
40/// routes to the plan sidecar, which rejects index 0 (`index_path components
41/// must be >= 1`); checklist-level `index: 0` completion exists only outside
42/// planning, so neither line advertises it.
43const PLANNING_TASK_TRACKER_COMPACT_LINE: &str = "- Keep blockers and verification open in `task_tracker`; updates use positive indices or index_path, and index 0 is invalid while planning.";
44const PLANNING_TASK_TRACKER_INDEX_LINE: &str = "- Use `task_tracker` action=update with positive flat indices or positive hierarchical index_path values (index 0 is invalid while planning), and use items only for full checklist replacement with descriptions and statuses, never JSON-encoded updates.";
45
46/// Context-token ceiling at or below which Active Tools documentation
47/// resolves to the Minimal density.
48const MINIMAL_CONTEXT_TOKEN_CEILING: usize = 32_000;
49
50/// The single home of the parallel-call hint, shared by the Minimal and
51/// Default Active Tools; emitted only when the provider keeps parallel tool
52/// configuration enabled.
53const PARALLEL_TOOLS_LINE: &str = "- Run independent tools in parallel when inputs do not depend on each other.";
54
55/// The single home of the `code_search` filter hygiene rule, shared by the
56/// Minimal, Default, and planning Active Tools variants.
57const CODE_SEARCH_FILTER_LINE: &str = "- `code_search`: omit unused filters; no empty values (`path: \"\"`).";
58
59/// The single home of the `request_user_input` threshold rule, shared by the
60/// Minimal planning addendum and the planning Active Tools.
61const REQUEST_USER_INPUT_LINE: &str =
62    "- Use `request_user_input` only for material blockers remaining after repository exploration.";
63
64/// Read-only capability line shared by the capability-level and
65/// tool-fallback arms of `capability_mode_line`.
66const CAPABILITY_READ_ONLY_LINE: &str =
67    "- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.";
68
69/// Shared `write_stdin` recovery clause; each profile adds its own
70/// rerun/wait policy around it.
71const WRITE_STDIN_RECOVERY_CLAUSE: &str = "; if missing, recover prior output";
72
73/// Policy lines shared by both shell profiles: the profile governs syntax
74/// examples only, and VT Code never translates flags across shells.
75const SHELL_PROFILE_POLICY_SUFFIX: &str = "- The shell profile controls prompt examples and expected command syntax only; command policy, sandboxing, and approvals remain separate runtime checks.\n- VT Code does not translate GNU-to-BSD, BSD-to-GNU, Unix-to-PowerShell, or PowerShell-to-Unix command flags.";
76
77/// Documentation density is independent of the tools a session may execute.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub(crate) enum ToolGuidanceProfile {
80    Minimal,
81    Default,
82}
83
84impl ToolGuidanceProfile {
85    #[must_use]
86    pub(crate) fn resolve(
87        context_tokens: usize,
88        default_prompt_tokens: usize,
89        max_prompt_tokens: usize,
90        input_usd_per_token: Option<f64>,
91        max_budget_usd: Option<f64>,
92    ) -> Self {
93        let exceeds_cost = input_usd_per_token.zip(max_budget_usd).is_some_and(|(price, budget)| {
94            price.is_finite() && price >= 0.0 && budget.is_finite() && default_prompt_tokens as f64 * price > budget
95        });
96        if (context_tokens > 0 && context_tokens <= MINIMAL_CONTEXT_TOKEN_CEILING)
97            || (max_prompt_tokens > 0 && default_prompt_tokens > max_prompt_tokens)
98            || exceeds_cost
99        {
100            Self::Minimal
101        } else {
102            Self::Default
103        }
104    }
105}
106
107/// Which guidance-gating tools a session exposes, computed once per build so
108/// every Active Tools variant branches on the same membership policy.
109struct ToolPresence {
110    exec: bool,
111    write_stdin: bool,
112    code_search: bool,
113    read_file: bool,
114    list_files: bool,
115    apply_patch: bool,
116    request_user_input: bool,
117    task_tracker: bool,
118    start_planning: bool,
119    matrix: bool,
120}
121
122impl ToolPresence {
123    fn of(available_tools: &[String]) -> Self {
124        let has = |name: &str| available_tools.iter().any(|tool| tool == name);
125        Self {
126            exec: has(TOOL_EXEC_COMMAND),
127            write_stdin: has(TOOL_WRITE_STDIN),
128            code_search: has(TOOL_CODE_SEARCH),
129            read_file: has(TOOL_READ_FILE),
130            list_files: has(TOOL_LIST_FILES),
131            apply_patch: has(TOOL_APPLY_PATCH),
132            request_user_input: has(TOOL_REQUEST_USER_INPUT),
133            task_tracker: has(TOOL_TASK_TRACKER),
134            start_planning: has(TOOL_START_PLANNING),
135            matrix: has("matrix"),
136        }
137    }
138}
139
140/// Render from resolved capabilities, without changing tool authorization.
141pub(crate) fn generate_tool_guidelines_with_capabilities(
142    available_tools: &[String],
143    capability_level: Option<CapabilityLevel>,
144    shell_profile: ResolvedShellPromptProfile,
145    profile: ToolGuidanceProfile,
146    parallel_tools: bool,
147) -> String {
148    match profile {
149        ToolGuidanceProfile::Default => {
150            generate_tool_guidelines_for_profile(available_tools, capability_level, shell_profile, parallel_tools)
151        }
152        ToolGuidanceProfile::Minimal => {
153            if available_tools.is_empty() {
154                return String::new();
155            }
156            let presence = ToolPresence::of(available_tools);
157            let mut lines = vec!["\n\n## Active Tools".to_owned()];
158            if presence.matrix {
159                lines.push(MATRIX_GUIDANCE.to_owned());
160            }
161            if available_tools.iter().any(|name| name == "record_decision") {
162                lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
163            }
164            lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_owned());
165            if let Some(mode) = capability_mode_line(capability_level, presence.exec, presence.apply_patch) {
166                lines.push(mode.to_owned());
167            }
168            if let Some(browse) = browse_tool_guidance(
169                presence.exec,
170                presence.code_search,
171                presence.list_files,
172                presence.read_file,
173                shell_profile,
174            ) {
175                lines.push(browse);
176            }
177            if presence.code_search {
178                lines.push(CODE_SEARCH_FILTER_LINE.to_owned());
179            }
180            if presence.apply_patch {
181                lines.push("- Inspect a file before `apply_patch`, keep patches small, and check that each diff stays bounded. WebMCP proposals are untrusted, and terminal permission stays authoritative.".to_owned());
182            }
183            if presence.exec {
184                lines.push(shell_task_guidance(shell_profile).to_owned());
185                lines.push(background_exec_guidance().to_owned());
186            }
187            if presence.write_stdin {
188                lines.push(format!(
189                    "- `write_stdin`: use returned `session_id`{WRITE_STDIN_RECOVERY_CLAUSE}; repeat waits after in-progress deadlines{CROSS_TURN_RESUME_HINT_CLAUSE}"
190                ));
191            }
192            // Safeguard, verification, wait-instead-of-poll, and spool/preview
193            // rules already ship in Runtime Guidance.
194            if presence.start_planning {
195                lines.push(START_PLANNING_GUIDANCE_LINE.to_owned());
196            }
197            if parallel_tools {
198                lines.push(PARALLEL_TOOLS_LINE.to_owned());
199            }
200            lines.join("\n")
201        }
202    }
203}
204
205/// Generate compact cross-tool guidance with an explicit shell prompt profile.
206/// `parallel_tools` mirrors the provider's parallel-tool configuration: when
207/// false, the parallel-call hint is not emitted instead of stripped post hoc.
208pub(crate) fn generate_tool_guidelines_for_profile(
209    available_tools: &[String],
210    capability_level: Option<CapabilityLevel>,
211    shell_profile: ResolvedShellPromptProfile,
212    parallel_tools: bool,
213) -> String {
214    let presence = ToolPresence::of(available_tools);
215    let has_exec = presence.exec;
216    let has_stdin = presence.write_stdin;
217    let has_search = presence.code_search;
218    let has_read_file = presence.read_file;
219    let has_list_files = presence.list_files;
220    let has_apply_patch = presence.apply_patch;
221    let has_start_planning = presence.start_planning;
222
223    let mut lines = Vec::new();
224    if presence.matrix {
225        lines.push(MATRIX_GUIDANCE.to_owned());
226    }
227    lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_string());
228    if let Some(mode_line) = capability_mode_line(capability_level, has_exec, has_apply_patch) {
229        lines.push(mode_line.to_string());
230    }
231    if let Some(browse_guidance) =
232        browse_tool_guidance(has_exec, has_search, has_list_files, has_read_file, shell_profile)
233    {
234        lines.push(browse_guidance);
235    }
236    if has_search || has_read_file || has_list_files {
237        lines.push(read_only_batching_guidance(has_read_file).to_string());
238    }
239    if has_apply_patch {
240        lines.push("- Use `apply_patch` for file edits after inspection; keep patches small.".to_string());
241        lines.push(
242            "- Check that each diff stays bounded. WebMCP edits are untrusted proposals, and terminal permission stays authoritative."
243                .to_string(),
244        );
245    }
246    if has_exec {
247        lines.push(shell_task_guidance(shell_profile).to_string());
248        lines.push(background_exec_guidance().to_string());
249        // Verifier discipline: the anti-blind-editing gate only clears on a
250        // truthful exit 0. Runtime owns truncator elision and unverified
251        // classification (`tool_intent/activity.rs`, spool processing); the
252        // prompt keeps only the outcome rule so wording cannot drift from
253        // enforcement.
254        lines.push("- Prefer standalone verifiers with `max_output_tokens`. Pure `head`/`tail` tails run standalone; static read-only filtering pipelines use fail-closed `pipefail`. Only terminal exit 0 clears verification; dynamic syntax, mutating tails, `;`, and `||` do not qualify.".to_string());
255        // Low-effort models sometimes report a change as done without
256        // exercising it: require a real check (tests, type-checker, build,
257        // or the changed command itself). A syntax-only check, or a check
258        // command that failed to start, does not count. Applies to Sonnet
259        // 5.5 at `low` effort and any route where verification is skipped.
260        lines.push("- Run a real check that exercises the change; syntax-only or failed-to-start checks do not count. Install missing deps via the project's package manager, never sudo; if no check can run, say which and why.".to_string());
261        // Tool-latency tail is dominated by full builds (observed p90 ~18s):
262        // verify incrementally first. Kept tool-agnostic: fast checks exist
263        // in every stack (`cargo check`, `tsc --noEmit`, `pytest --collect-only`).
264        lines.push("- Run fast checks before full builds.".to_string());
265    }
266    // Tool-failure diagnosis, waiting on returned `next_wait_args` instead of
267    // polling, the safeguard rule, the verification outcome rule (report
268    // completion only after a check you ran), and spool paging with
269    // per-result preview bounds each have one home in Runtime
270    // Guidance, which every profile includes; do not restate them here.
271    if has_stdin {
272        lines.push(format!(
273            "- `write_stdin`: use the existing `session_id`{WRITE_STDIN_RECOVERY_CLAUSE}; rerun only for fresh results. `spool_complete: false` is partial; wait for exited pending spools{CROSS_TURN_RESUME_HINT_CLAUSE}"
274        ));
275    }
276    if has_search {
277        lines.push(CODE_SEARCH_FILTER_LINE.to_string());
278        lines.push(code_search_guidance(has_exec, shell_profile));
279    }
280    if has_apply_patch || has_exec {
281        lines.push(
282            "- Build and Auto share tools and safety gates; Auto changes confirmation behavior only after explicit approval or full-auto policy."
283                .to_string(),
284        );
285    }
286    if (has_search || has_exec) && parallel_tools {
287        lines.push(PARALLEL_TOOLS_LINE.to_string());
288    }
289    if has_start_planning {
290        lines.push(START_PLANNING_GUIDANCE_LINE.to_string());
291    }
292
293    if lines.is_empty() {
294        return String::new();
295    }
296
297    if available_tools.iter().any(|name| name == "record_decision") {
298        lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
299    }
300    format!("\n\n## Active Tools\n{}", lines.join("\n"))
301}
302
303pub fn append_runtime_tool_prompt_sections_for_profile(
304    prompt: &mut String,
305    tool_snapshot: &SessionToolCatalogSnapshot,
306    include_catalog_metadata: bool,
307    shell_profile: ResolvedShellPromptProfile,
308) {
309    let names = snapshot_tool_names(tool_snapshot);
310    append_runtime_tool_sections_with_names(prompt, tool_snapshot, &names, include_catalog_metadata, shell_profile);
311}
312
313/// Select documentation density using the active route and session budgets.
314pub fn append_runtime_tool_prompt_sections_for_model(
315    prompt: &mut String,
316    tool_snapshot: &SessionToolCatalogSnapshot,
317    include_catalog_metadata: bool,
318    shell_profile: ResolvedShellPromptProfile,
319    provider: &dyn crate::llm::provider::LLMProvider,
320    model: &str,
321    config: Option<&crate::config::VTCodeConfig>,
322) {
323    let names = snapshot_tool_names(tool_snapshot);
324    let default_guidance =
325        append_runtime_tool_sections_with_names(prompt, tool_snapshot, &names, include_catalog_metadata, shell_profile);
326    let pricing = crate::config::models::model_catalog_entry(provider.name(), model).map(|entry| entry.pricing);
327    let profile = ToolGuidanceProfile::resolve(
328        crate::compaction::effective_context_budget(config, provider, model),
329        vtcode_commons::estimate_tokens(prompt),
330        config.map_or(0, |cfg| cfg.agent.max_system_prompt_tokens as usize),
331        pricing.and_then(|price| price.input),
332        config.and_then(|cfg| cfg.agent.harness.max_budget_usd),
333    );
334    let parallel_tools = provider.supports_parallel_tool_config(model);
335    // The planning mode section owns the output contract at every density.
336    // Default planning tool guidance contains no parallel-call hint.
337    if tool_snapshot.planning_active && profile == ToolGuidanceProfile::Default {
338        return;
339    }
340    remove_prompt_section(prompt, "## Active Tools");
341    let capability_level = Some(infer_capability_level(&names));
342    let mut guidance = match profile {
343        ToolGuidanceProfile::Default => {
344            let mut guidance = default_guidance;
345            let presence = ToolPresence::of(&names);
346            if let (Some(original), Some(effective)) = (
347                capability_mode_line(None, presence.exec, presence.apply_patch),
348                capability_mode_line(capability_level, presence.exec, presence.apply_patch),
349            ) && original != effective
350                && let Some(start) = guidance.find(original)
351            {
352                guidance.replace_range(start..start + original.len(), effective);
353            }
354            if !parallel_tools && let Some(start) = guidance.find(PARALLEL_TOOLS_LINE) {
355                guidance.replace_range(start.saturating_sub(1)..start + PARALLEL_TOOLS_LINE.len(), "");
356            }
357            guidance
358        }
359        ToolGuidanceProfile::Minimal => {
360            generate_tool_guidelines_with_capabilities(&names, capability_level, shell_profile, profile, parallel_tools)
361        }
362    };
363    if tool_snapshot.planning_active {
364        append_minimal_planning_addendum(&mut guidance, &names);
365    }
366    append_prompt_block(prompt, guidance.trim_start_matches('\n'));
367}
368
369/// Replace the runtime tool sections with guidance built from `names`, plus
370/// optional catalog metadata. Return the Default guidance so model-aware
371/// callers can reuse it after measuring the assembled prompt.
372fn append_runtime_tool_sections_with_names(
373    prompt: &mut String,
374    tool_snapshot: &SessionToolCatalogSnapshot,
375    names: &[String],
376    include_catalog_metadata: bool,
377    shell_profile: ResolvedShellPromptProfile,
378) -> String {
379    remove_prompt_section(prompt, "## Active Tools");
380    remove_prompt_section(prompt, "[Runtime Tool Catalog]");
381    while prompt.ends_with('\n') {
382        prompt.pop();
383    }
384
385    let guidelines = generate_runtime_tool_guidelines_for_profile(names, tool_snapshot.planning_active, shell_profile);
386    if !guidelines.is_empty() {
387        append_prompt_block(prompt, guidelines.trim_start_matches('\n'));
388    }
389
390    if include_catalog_metadata && tool_snapshot.snapshot.is_some() {
391        let active_tools = if tool_snapshot.active_tool_names.is_empty() {
392            "none".to_string()
393        } else {
394            tool_snapshot.active_tool_names.join(", ")
395        };
396        let catalog_metadata = format!(
397            "[Runtime Tool Catalog]\n- version: {}\n- epoch: {}\n- catalog_tools: {}\n- available_tools: {}\n- currently_available_tools: {}\n- request_user_input_enabled: {}",
398            tool_snapshot.version,
399            tool_snapshot.epoch,
400            tool_snapshot.catalog_tools(),
401            tool_snapshot.available_tools(),
402            active_tools,
403            tool_snapshot.request_user_input_enabled,
404        );
405        append_prompt_block(prompt, &catalog_metadata);
406    }
407    guidelines
408}
409
410/// Compact tool reminders; the planning mode section retains the canonical
411/// research, step-quality, output, and persistence contract at every density.
412fn append_minimal_planning_addendum(guidance: &mut String, names: &[String]) {
413    guidance.push_str("\n- Planning is read-only.");
414    let read_tools = [TOOL_READ_FILE, TOOL_GREP_FILE, TOOL_CODE_SEARCH, TOOL_LIST_FILES]
415        .into_iter()
416        .filter(|tool| names.iter().any(|name| name == tool))
417        .collect::<Vec<_>>();
418    if read_tools.is_empty() {
419        guidance.push_str("\n- Keep inspections small: keep `max_output_tokens` small, avoid batching multiple large inspections in parallel; start git history with `git log --oneline` before targeted `git show --stat`.");
420    } else {
421        guidance.push_str(&format!(
422            "\n- Keep inspections small: prefer `{}` over `exec_command` shell reads; keep `max_output_tokens` small, avoid batching multiple large inspections in parallel; start git history with `git log --oneline` before targeted `git show --stat`.",
423            read_tools.join("`/`")
424        ));
425    }
426    if names.iter().any(|name| name == TOOL_TASK_TRACKER) {
427        guidance.push('\n');
428        guidance.push_str(PLANNING_TASK_TRACKER_COMPACT_LINE);
429    }
430    if names.iter().any(|name| name == TOOL_REQUEST_USER_INPUT) {
431        guidance.push('\n');
432        guidance.push_str(REQUEST_USER_INPUT_LINE);
433    }
434}
435
436/// Append a compact summary of tools omitted from a client-local wire payload.
437///
438/// The listing is bounded like the `## Skills` routing section: at most
439/// `DEFERRED_TOOLS_MAX_GROUPS` groups are named and the rest collapse into one
440/// overflow line, so MCP-heavy sessions cannot bloat every request. Group
441/// descriptions are server-provided and unbounded, so each line is truncated
442/// to `DEFERRED_TOOLS_MAX_DESC_CHARS` characters.
443pub fn append_deferred_tools_prompt_section(prompt: &mut String, tools: &[ToolDefinition]) {
444    remove_prompt_section(prompt, "[Deferred Tools]");
445
446    let mut groups: Vec<_> = tool_groups(tools)
447        .into_iter()
448        .filter(|group| group.deferred_count > 0)
449        .collect();
450    let overflow = groups.len().saturating_sub(DEFERRED_TOOLS_MAX_GROUPS);
451    groups.truncate(DEFERRED_TOOLS_MAX_GROUPS);
452
453    let mut lines: Vec<String> = groups
454        .into_iter()
455        .map(|group| {
456            let description = truncate_deferred_description(group.description.as_deref().unwrap_or_default());
457            format!("- {} ({} tools): {}", group.name, group.deferred_count, description)
458        })
459        .collect();
460    if overflow > 0 {
461        lines.push(format!("(+{overflow} more deferred groups available — use `search_tools` to find them)"));
462    }
463
464    let unnamespaced_deferred = tools
465        .iter()
466        .filter(|tool| tool.namespace.is_none() && tool.defer_loading == Some(true))
467        .count();
468    if unnamespaced_deferred > 0 {
469        lines.push(format!("- {unnamespaced_deferred} additional deferred tools"));
470    }
471
472    if lines.is_empty() {
473        return;
474    }
475
476    let section = format!(
477        "[Deferred Tools]\n{}\nUse `search_tools` to find a deferred capability. Selected definitions become available in the next request segment.",
478        lines.join("\n")
479    );
480    append_prompt_block(prompt, &section);
481}
482
483/// Maximum deferred-tool groups named in the `[Deferred Tools]` prompt
484/// section before the rest collapse into one overflow line.
485const DEFERRED_TOOLS_MAX_GROUPS: usize = 8;
486/// Maximum characters of a group description in the section. Mirrors the
487/// `## Skills` line truncation: server-provided text must not bloat the prompt.
488const DEFERRED_TOOLS_MAX_DESC_CHARS: usize = 120;
489
490fn truncate_deferred_description(description: &str) -> String {
491    if description.chars().count() <= DEFERRED_TOOLS_MAX_DESC_CHARS {
492        return description.to_string();
493    }
494    let truncated: String = description.chars().take(DEFERRED_TOOLS_MAX_DESC_CHARS).collect();
495    format!("{}...", truncated.trim_end())
496}
497
498fn append_prompt_block(prompt: &mut String, block: &str) {
499    if block.is_empty() {
500        return;
501    }
502
503    if prompt.is_empty() {
504        prompt.push_str(block);
505    } else {
506        let _ = write!(prompt, "\n\n{block}");
507    }
508}
509
510fn remove_prompt_section(prompt: &mut String, section_header: &str) {
511    while let Some((section_start, section_end)) = find_prompt_section_bounds(prompt, section_header) {
512        prompt.replace_range(section_start..section_end, "");
513    }
514}
515
516fn find_prompt_section_bounds(prompt: &str, section_header: &str) -> Option<(usize, usize)> {
517    crate::prompts::sections::find_prompt_section_bounds(prompt, section_header, SectionBoundaryMode::BracketOrMarkdown)
518}
519
520fn generate_runtime_tool_guidelines_for_profile(
521    available_tools: &[String],
522    planning_active: bool,
523    shell_profile: ResolvedShellPromptProfile,
524) -> String {
525    if !planning_active {
526        // Measurement parity: this Default-density build feeds the prompt size
527        // that `ToolGuidanceProfile::resolve` reads, so it keeps shipping the
528        // parallel-call hint unconditionally like the static composition path.
529        return generate_tool_guidelines_for_profile(available_tools, None, shell_profile, true);
530    }
531
532    let presence = ToolPresence::of(available_tools);
533    let has_exec = presence.exec;
534    let has_search = presence.code_search;
535    let has_read_file = presence.read_file;
536    let has_list_files = presence.list_files;
537    let has_request_user_input = presence.request_user_input;
538    let has_task_tracker = presence.task_tracker;
539
540    let mut lines = vec!["- Planning workflow active: stay within the read-safe tool list.".to_string()];
541    lines.push(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE.to_string());
542    if let Some(browse_guidance) =
543        browse_tool_guidance(has_exec, has_search, has_list_files, has_read_file, shell_profile)
544    {
545        lines.push(browse_guidance);
546    }
547    if has_exec {
548        lines.push("- In Planning workflow, use `exec_command` only for read-only verification.".to_string());
549    }
550    if has_search {
551        lines.push(CODE_SEARCH_FILTER_LINE.to_string());
552    }
553    if has_task_tracker {
554        lines.push("- Keep `task_tracker` updated as you refine the plan. Indexed updates return totals and the changed item; use action=list for the full checklist.".to_string());
555        lines.push("- Keep blockers and verification open in `task_tracker` until resolved.".to_string());
556        lines.push(PLANNING_TASK_TRACKER_INDEX_LINE.to_string());
557    }
558    if has_request_user_input {
559        lines.push(REQUEST_USER_INPUT_LINE.to_string());
560    }
561    if has_search || has_exec {
562        lines.push("- If calls repeat without progress, tighten the plan instead of retrying identically.".to_string());
563    }
564    if available_tools.iter().any(|name| name == "record_decision") {
565        lines.push(PUBLIC_DECISION_GUIDANCE.to_owned());
566    }
567
568    format!("\n\n## Active Tools\n{}", lines.join("\n"))
569}
570
571fn snapshot_tool_names(tool_snapshot: &SessionToolCatalogSnapshot) -> Vec<String> {
572    tool_snapshot
573        .active_tool_names
574        .iter()
575        .cloned()
576        .collect::<BTreeSet<_>>()
577        .into_iter()
578        .collect()
579}
580
581fn browse_tool_guidance(
582    has_exec: bool,
583    has_search: bool,
584    has_list_files: bool,
585    has_read_file: bool,
586    shell_profile: ResolvedShellPromptProfile,
587) -> Option<String> {
588    if has_exec {
589        return Some(shell_browse_guidance(shell_profile, has_search));
590    }
591
592    if !(has_search || has_list_files || has_read_file) {
593        return None;
594    }
595
596    Some("- Use available read-only repository tools for browsing; do not modify files.".to_string())
597}
598
599pub(crate) fn render_shell_profile_guidance(shell_profile: ResolvedShellPromptProfile) -> String {
600    let profile_lines = match shell_profile {
601        ResolvedShellPromptProfile::UnixLike => {
602            "- Active shell profile: `unix_like`. Use Unix-like command syntax in `exec_command.cmd`, for example `ls`, `rg`, `find`, `cat`, `sed`, and `awk`.\n- On macOS, write BSD-compatible flags for BSD tools. VT Code does not rewrite GNU flags for macOS BSD tools."
603        }
604        ResolvedShellPromptProfile::PowerShell => {
605            "- Active shell profile: `powershell`. Use native PowerShell syntax in `exec_command.cmd`, for example `Get-ChildItem`, `Select-String`, `Get-Content`, and `Where-Object`.\n- On native Windows, use WSL when you need Unix-like workflows or Unix command examples."
606        }
607    };
608    format!("## Shell Profile\n{profile_lines}\n{SHELL_PROFILE_POLICY_SUFFIX}")
609}
610
611fn shell_browse_guidance(shell_profile: ResolvedShellPromptProfile, has_search: bool) -> String {
612    // Sessions show `exec_command` + `rg` crowding out `code_search`
613    // (observed 654 exec vs 15 code_search in one run): `rg` scans text
614    // while `code_search` resolves definitions and exact usages, so route
615    // code search to the dedicated tool whenever it is available.
616    const SEARCH_PREFERENCE_UNIX: &str = " Prefer `code_search` over `rg`/`grep` for code.";
617    const SEARCH_PREFERENCE_POWERSHELL: &str = " Prefer `code_search` over `Select-String` for code.";
618    match shell_profile {
619        ResolvedShellPromptProfile::UnixLike => {
620            let mut line = if has_search {
621                "- Use `exec_command.cmd` with `ls`, `find`, `cat`, `sed`, and `awk` for repository browsing."
622                    .to_string()
623            } else {
624                "- Use `exec_command.cmd` with `ls`, `rg`, `find`, `cat`, `sed`, and `awk` for repository browsing."
625                    .to_string()
626            };
627            if has_search {
628                line.push_str(SEARCH_PREFERENCE_UNIX);
629            }
630            line
631        }
632        ResolvedShellPromptProfile::PowerShell => {
633            let mut line = "- Use `exec_command.cmd` with native PowerShell commands such as `Get-ChildItem`, `Select-String`, `Get-Content`, and `Where-Object` for repository browsing.".to_string();
634            if has_search {
635                line.push_str(SEARCH_PREFERENCE_POWERSHELL);
636            }
637            line
638        }
639    }
640}
641
642fn shell_task_guidance(shell_profile: ResolvedShellPromptProfile) -> &'static str {
643    match shell_profile {
644        ResolvedShellPromptProfile::UnixLike => {
645            "- Use `exec_command.cmd` for build tools, test tools, `git diff -- <path>`, and shell-only tasks. In one-shot `exec_command` calls, do not use `!!`, `!$`, `!ssh`, or `fc`; write full command arguments explicitly from conversation or tool results. Interactive shells: review-safe history expansion (Bash `histverify`, zsh `HIST_VERIFY`)."
646        }
647        ResolvedShellPromptProfile::PowerShell => {
648            "- Use `exec_command.cmd` for build tools, test tools, `git diff -- <path>`, and shell-only tasks using native PowerShell syntax."
649        }
650    }
651}
652
653fn background_exec_guidance() -> &'static str {
654    "- For long-lived commands, set `background: true` on `exec_command`; it returns a bounded preview plus a stable `session_id` and wait arguments. At most three live background processes are retained per runtime, with no automatic eviction; `write_stdin` drives the session lifecycle."
655}
656
657fn read_only_batching_guidance(has_read_file: bool) -> &'static str {
658    if has_read_file {
659        "- Batch independent read-only calls; use bounded `read_file` ranges, order dependencies, serialize mutations; narrow the range on `line_truncated`."
660    } else {
661        "- Batch independent read-only calls; order dependent reads, and serialize mutations."
662    }
663}
664
665fn code_search_guidance(has_exec: bool, _shell_profile: ResolvedShellPromptProfile) -> String {
666    const BASE: &str = "- Advanced `code_search` takes `query`; filters `path`, `file_types`, `result_types`, `max_results`; results: definitions, exact syntactic usages. Queries use literal smart-case and `|`-separated literals; truncated: narrow. Example: `{\"query\":\"TurnLoop\",\"path\":\"src\",\"result_types\":[\"definition\"]}`. Do not JSON-encode arrays or integers as strings. Prefer `code_search` over `rg` on `.vtcode/context/tool_outputs/`.";
667    if has_exec {
668        format!("{BASE} Use `exec_command` or a skill for syntax patterns.")
669    } else {
670        BASE.to_string()
671    }
672}
673
674fn capability_mode_line(
675    capability_level: Option<CapabilityLevel>,
676    has_exec: bool,
677    has_file: bool,
678) -> Option<&'static str> {
679    match capability_level {
680        Some(CapabilityLevel::Basic) => {
681            Some("- Capabilities: limited. Ask the user to enable more capabilities if file work is required.")
682        }
683        Some(CapabilityLevel::FileReading | CapabilityLevel::FileListing) => Some(CAPABILITY_READ_ONLY_LINE),
684        _ if !has_exec && !has_file => Some(CAPABILITY_READ_ONLY_LINE),
685        _ => None,
686    }
687}
688
689/// Infer capability level from available tools.
690pub(crate) fn infer_capability_level(available_tools: &[String]) -> CapabilityLevel {
691    let presence = ToolPresence::of(available_tools);
692    if presence.code_search {
693        CapabilityLevel::CodeSearch
694    } else if presence.apply_patch {
695        CapabilityLevel::Editing
696    } else if presence.exec {
697        CapabilityLevel::Bash
698    } else if presence.list_files {
699        CapabilityLevel::FileListing
700    } else if presence.read_file {
701        CapabilityLevel::FileReading
702    } else {
703        CapabilityLevel::Basic
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use crate::config::types::ShellPromptProfile;
711
712    /// Default-density guidance with the platform shell profile and the
713    /// parallel-call hint, matching what static composition ships.
714    fn guidelines_for(tools: &[String], capability_level: Option<CapabilityLevel>) -> String {
715        generate_tool_guidelines_for_profile(
716            tools,
717            capability_level,
718            ShellPromptProfile::Auto.resolve_for_current_platform(),
719            true,
720        )
721    }
722
723    /// Reference the previous model-aware assembly: measure Default guidance,
724    /// then regenerate it with resolved density and capabilities.
725    fn rebuilt_model_tool_prompt(
726        mut prompt: String,
727        snapshot: &SessionToolCatalogSnapshot,
728        metadata: bool,
729        shell: ResolvedShellPromptProfile,
730        provider: &dyn crate::llm::provider::LLMProvider,
731        model: &str,
732        config: &crate::config::VTCodeConfig,
733    ) -> String {
734        append_runtime_tool_prompt_sections_for_profile(&mut prompt, snapshot, metadata, shell);
735        let names = snapshot_tool_names(snapshot);
736        let pricing = crate::config::models::model_catalog_entry(provider.name(), model).map(|entry| entry.pricing);
737        let profile = ToolGuidanceProfile::resolve(
738            crate::compaction::effective_context_budget(Some(config), provider, model),
739            vtcode_commons::estimate_tokens(&prompt),
740            config.agent.max_system_prompt_tokens as usize,
741            pricing.and_then(|price| price.input),
742            config.agent.harness.max_budget_usd,
743        );
744        if snapshot.planning_active && profile == ToolGuidanceProfile::Default {
745            return prompt;
746        }
747        remove_prompt_section(&mut prompt, "## Active Tools");
748        let mut guidance = generate_tool_guidelines_with_capabilities(
749            &names,
750            Some(infer_capability_level(&names)),
751            shell,
752            profile,
753            provider.supports_parallel_tool_config(model),
754        );
755        if snapshot.planning_active {
756            append_minimal_planning_addendum(&mut guidance, &names);
757        }
758        append_prompt_block(&mut prompt, guidance.trim_start_matches('\n'));
759        prompt
760    }
761
762    #[test]
763    fn model_tool_prompt_reuse_matches_rebuild_across_density_and_capabilities() {
764        use crate::config::constants::models;
765        use crate::llm::provider::LLMProvider;
766        use crate::llm::providers::{AnthropicProvider, HuggingFaceProvider};
767
768        struct ProviderCase {
769            provider: Box<dyn LLMProvider>,
770            model: &'static str,
771            parallel: bool,
772        }
773        let providers = [
774            ProviderCase {
775                provider: Box::new(AnthropicProvider::new("offline-fixture".into())),
776                model: models::anthropic::DEFAULT_MODEL,
777                parallel: true,
778            },
779            ProviderCase {
780                provider: Box::new(HuggingFaceProvider::new("offline-fixture".into())),
781                model: "offline-fixture",
782                parallel: false,
783            },
784        ];
785        let initial = "Base prompt\n\n## Active Tools\nold guidance\n\n# PLANNING WORKFLOW (READ-ONLY)\nKeep the canonical contract.\n\n[Runtime Tool Catalog]\nold metadata\n\n## Tail\nKeep this section.\n\n";
786        for case in providers {
787            assert_eq!(case.provider.supports_parallel_tool_config(case.model), case.parallel);
788            for names in [
789                None,
790                Some(&[][..]),
791                Some(&[TOOL_TASK_TRACKER, TOOL_REQUEST_USER_INPUT][..]),
792                Some(&[TOOL_READ_FILE][..]),
793                Some(&[TOOL_WRITE_STDIN][..]),
794                Some(&[TOOL_EXEC_COMMAND, TOOL_APPLY_PATCH, TOOL_CODE_SEARCH, "record_decision"][..]),
795            ] {
796                for planning in [false, true] {
797                    let snapshot = SessionToolCatalogSnapshot::new(
798                        7,
799                        9,
800                        planning,
801                        true,
802                        names.map(|names| {
803                            std::sync::Arc::new(
804                                names
805                                    .iter()
806                                    .map(|name| {
807                                        ToolDefinition::function(
808                                            (*name).to_string(),
809                                            "fixture".to_string(),
810                                            serde_json::json!({"type": "object"}),
811                                        )
812                                    })
813                                    .collect(),
814                            )
815                        }),
816                        false,
817                    );
818                    for shell in [
819                        ResolvedShellPromptProfile::UnixLike,
820                        ResolvedShellPromptProfile::PowerShell,
821                    ] {
822                        for metadata in [false, true] {
823                            let mut measured = initial.to_string();
824                            append_runtime_tool_prompt_sections_for_profile(&mut measured, &snapshot, metadata, shell);
825                            let measured_tokens = vtcode_commons::estimate_tokens(&measured) as u64;
826                            for budget in [1, measured_tokens - 1, measured_tokens, 100_000] {
827                                let mut config = crate::config::VTCodeConfig::default();
828                                config.context.max_context_tokens = 128_000;
829                                config.agent.max_system_prompt_tokens = budget;
830                                let expected = rebuilt_model_tool_prompt(
831                                    initial.to_string(),
832                                    &snapshot,
833                                    metadata,
834                                    shell,
835                                    case.provider.as_ref(),
836                                    case.model,
837                                    &config,
838                                );
839                                let mut actual = initial.to_string();
840                                append_runtime_tool_prompt_sections_for_model(
841                                    &mut actual,
842                                    &snapshot,
843                                    metadata,
844                                    shell,
845                                    case.provider.as_ref(),
846                                    case.model,
847                                    Some(&config),
848                                );
849
850                                assert_eq!(
851                                    actual,
852                                    expected,
853                                    "provider={}, names={names:?}, planning={planning}, shell={shell:?}, metadata={metadata}, budget={budget}",
854                                    case.provider.name()
855                                );
856                                assert_eq!(
857                                    actual.matches("## Active Tools").count(),
858                                    usize::from(
859                                        budget >= measured_tokens || names.is_some_and(|names| !names.is_empty())
860                                    )
861                                );
862                                assert!(
863                                    actual.contains("# PLANNING WORKFLOW (READ-ONLY)\nKeep the canonical contract.")
864                                );
865                                assert!(actual.contains("## Tail\nKeep this section."));
866                            }
867                        }
868                    }
869                }
870            }
871        }
872    }
873
874    #[test]
875    fn matrix_guidance_has_presence_and_budget_in_both_densities() {
876        for profile in [ToolGuidanceProfile::Default, ToolGuidanceProfile::Minimal] {
877            let enabled = generate_tool_guidelines_with_capabilities(
878                &["matrix".to_string(), "request_user_input".to_string()],
879                None,
880                ResolvedShellPromptProfile::UnixLike,
881                profile,
882                false,
883            );
884            let disabled = generate_tool_guidelines_with_capabilities(
885                &["request_user_input".to_string()],
886                None,
887                ResolvedShellPromptProfile::UnixLike,
888                profile,
889                false,
890            );
891            assert_eq!(enabled.matches(MATRIX_GUIDANCE).count(), 1);
892            assert!(enabled.contains("declared input changes"));
893            assert!(enabled.contains("effective permissions and sandbox gates"));
894            assert!(enabled.contains("Replay restores admission before discovery"));
895            assert!(enabled.contains("user cancel is terminal"));
896            assert!(!disabled.contains("scheduler-owned"));
897            assert!(vtcode_commons::estimate_tokens(&enabled) <= 220, "{profile:?}: {enabled}");
898        }
899    }
900
901    /// Universal rules have one home in Runtime Guidance (or the shared
902    /// contract). Compose every static profile with each tool-guidance variant
903    /// and the Harness Limits section, and check each rule marker lands once.
904    #[test]
905    fn universal_rules_have_one_home_across_composed_prompt_sections() {
906        use crate::config::types::SystemPromptMode;
907        use crate::prompts::harness_limits::upsert_harness_limits_section;
908        use crate::prompts::static_prompts::static_profile_prompt;
909        use crate::prompts::system::PLANNING_WORKFLOW_READ_ONLY_NOTICE_LINE;
910
911        let shell = ResolvedShellPromptProfile::UnixLike;
912        let execution_tools = [
913            TOOL_EXEC_COMMAND,
914            TOOL_WRITE_STDIN,
915            TOOL_APPLY_PATCH,
916            TOOL_CODE_SEARCH,
917            TOOL_TASK_TRACKER,
918            TOOL_START_PLANNING,
919            TOOL_REQUEST_USER_INPUT,
920        ]
921        .map(str::to_owned)
922        .to_vec();
923        let planning_tools = [
924            TOOL_EXEC_COMMAND,
925            TOOL_WRITE_STDIN,
926            TOOL_CODE_SEARCH,
927            TOOL_GREP_FILE,
928            TOOL_TASK_TRACKER,
929            TOOL_REQUEST_USER_INPUT,
930        ]
931        .map(str::to_owned)
932        .to_vec();
933        let mut minimal_planning = generate_tool_guidelines_with_capabilities(
934            &planning_tools,
935            None,
936            shell,
937            ToolGuidanceProfile::Minimal,
938            false,
939        );
940        append_minimal_planning_addendum(&mut minimal_planning, &planning_tools);
941        let variants = [
942            ("default", generate_tool_guidelines_for_profile(&execution_tools, None, shell, true), false),
943            (
944                "minimal",
945                generate_tool_guidelines_with_capabilities(
946                    &execution_tools,
947                    None,
948                    shell,
949                    ToolGuidanceProfile::Minimal,
950                    true,
951                ),
952                false,
953            ),
954            ("default planning", generate_runtime_tool_guidelines_for_profile(&planning_tools, true, shell), true),
955            ("minimal planning", minimal_planning, true),
956        ];
957        // Each marker names one universal rule that lives in Runtime Guidance
958        // or the shared contract and must not be restated by tool sections.
959        let markers = [
960            "never claim a check passed",
961            "Diagnose failures; change approach",
962            "Use returned `next_wait_args`",
963            "Treat empty searches as evidence",
964            "Check optional tools once",
965            "small non-overlapping ranges",
966            "accumulated output never exhausts tool access",
967            "additional_permissions",
968            "bypass safeguards",
969            "Delegate only sizeable",
970            "Across compaction",
971        ];
972
973        for mode in [
974            SystemPromptMode::Default,
975            SystemPromptMode::Minimal,
976            SystemPromptMode::Lightweight,
977            SystemPromptMode::Specialized,
978        ] {
979            for (variant, guidance, planning) in &variants {
980                let mut prompt = static_profile_prompt(mode).to_owned();
981                if *planning {
982                    prompt.push_str("\n\n");
983                    prompt.push_str(PLANNING_WORKFLOW_READ_ONLY_NOTICE_LINE);
984                }
985                prompt.push_str(guidance);
986                upsert_harness_limits_section(&mut prompt, 32, 600, 2);
987                for marker in markers {
988                    assert_eq!(
989                        prompt.matches(marker).count(),
990                        1,
991                        "{mode:?} with {variant} tool guidance should state {marker:?} exactly once"
992                    );
993                }
994                let start_planning_mentions = prompt.matches("start_planning").count();
995                assert_eq!(start_planning_mentions, usize::from(!*planning), "{mode:?} with {variant} tool guidance");
996            }
997        }
998    }
999
1000    #[test]
1001    fn documentation_profile_respects_context_tokens_and_known_cost() {
1002        assert_eq!(ToolGuidanceProfile::resolve(32_000, 100, 1000, None, None), ToolGuidanceProfile::Minimal);
1003        assert_eq!(ToolGuidanceProfile::resolve(1_000_000, 100, 1000, None, Some(0.0)), ToolGuidanceProfile::Default);
1004        assert_eq!(ToolGuidanceProfile::resolve(1_000_000, 1001, 1000, None, None), ToolGuidanceProfile::Minimal);
1005        assert_eq!(
1006            ToolGuidanceProfile::resolve(1_000_000, 1000, 2000, Some(0.00001), Some(0.001)),
1007            ToolGuidanceProfile::Minimal
1008        );
1009    }
1010
1011    #[test]
1012    fn minimal_and_default_tool_guidance_snapshots() {
1013        let tools = vec![TOOL_READ_FILE.to_owned()];
1014        let minimal = generate_tool_guidelines_with_capabilities(
1015            &tools,
1016            None,
1017            ResolvedShellPromptProfile::UnixLike,
1018            ToolGuidanceProfile::Minimal,
1019            false,
1020        );
1021        assert_eq!(
1022            minimal,
1023            "\n\n## Active Tools\n- `verify: [skip Markdown lint if unavailable]`: report skipped; review diff/links without installing tools. Lint errors remain failures.\n- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.\n- Use available read-only repository tools for browsing; do not modify files."
1024        );
1025        let default = generate_tool_guidelines_with_capabilities(
1026            &tools,
1027            None,
1028            ResolvedShellPromptProfile::UnixLike,
1029            ToolGuidanceProfile::Default,
1030            false,
1031        );
1032        assert_eq!(
1033            default,
1034            "\n\n## Active Tools\n- `verify: [skip Markdown lint if unavailable]`: report skipped; review diff/links without installing tools. Lint errors remain failures.\n- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.\n- Use available read-only repository tools for browsing; do not modify files.\n- Batch independent read-only calls; use bounded `read_file` ranges, order dependencies, serialize mutations; narrow the range on `line_truncated`."
1035        );
1036    }
1037
1038    #[test]
1039    fn public_decision_guidance_is_optional_and_bounded() {
1040        for profile in [ToolGuidanceProfile::Minimal, ToolGuidanceProfile::Default] {
1041            let enabled = generate_tool_guidelines_with_capabilities(
1042                &[TOOL_READ_FILE.into(), "record_decision".into()],
1043                None,
1044                ResolvedShellPromptProfile::UnixLike,
1045                profile,
1046                false,
1047            );
1048            let disabled = generate_tool_guidelines_with_capabilities(
1049                &[TOOL_READ_FILE.into()],
1050                None,
1051                ResolvedShellPromptProfile::UnixLike,
1052                profile,
1053                false,
1054            );
1055            assert!(enabled.contains(PUBLIC_DECISION_GUIDANCE));
1056            assert!(!disabled.contains("record_decision"));
1057            assert!(enabled.len().saturating_sub(disabled.len()) < 300);
1058            assert!(!enabled.contains("chain of thought"));
1059        }
1060    }
1061
1062    #[test]
1063    fn tool_guidance_uses_actual_parallel_capabilities_for_three_families() {
1064        use crate::config::constants::models;
1065        use crate::llm::provider::LLMProvider;
1066        use crate::llm::providers::{AnthropicProvider, GeminiProvider, OpenAIProvider};
1067        let providers: [(Box<dyn LLMProvider>, &str); 3] = [
1068            (Box::new(OpenAIProvider::new("offline-fixture".into())), models::openai::DEFAULT_MODEL),
1069            (Box::new(AnthropicProvider::new("offline-fixture".into())), models::anthropic::DEFAULT_MODEL),
1070            (Box::new(GeminiProvider::new("offline-fixture".into())), models::google::DEFAULT_MODEL),
1071        ];
1072        for (provider, model) in providers {
1073            for profile in [ToolGuidanceProfile::Minimal, ToolGuidanceProfile::Default] {
1074                let parallel = provider.supports_parallel_tool_config(model);
1075                let text = generate_tool_guidelines_with_capabilities(
1076                    &[TOOL_EXEC_COMMAND.to_owned()],
1077                    None,
1078                    ResolvedShellPromptProfile::UnixLike,
1079                    profile,
1080                    parallel,
1081                );
1082                assert_eq!(text.contains("tools in parallel"), parallel);
1083                let serial = generate_tool_guidelines_with_capabilities(
1084                    &[TOOL_EXEC_COMMAND.to_owned()],
1085                    None,
1086                    ResolvedShellPromptProfile::UnixLike,
1087                    profile,
1088                    false,
1089                );
1090                assert!(!serial.contains("tools in parallel"));
1091            }
1092        }
1093    }
1094
1095    #[test]
1096    fn test_read_only_capability_detection() {
1097        let tools = vec![TOOL_CODE_SEARCH.to_string()];
1098        let guidelines = guidelines_for(&tools, None);
1099        assert!(guidelines.contains("Capabilities: read-only"));
1100        assert!(guidelines.contains("do not modify files"));
1101    }
1102
1103    #[test]
1104    fn test_tool_preference_guidance() {
1105        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_CODE_SEARCH.to_string()];
1106        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1107        assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1108        assert!(guidelines.contains("literal smart-case"));
1109        assert!(guidelines.contains("exact syntactic usages"));
1110        assert!(guidelines.contains("\"result_types\":[\"definition\"]"));
1111        assert!(guidelines.contains("Do not JSON-encode arrays or integers as strings"));
1112        assert!(guidelines.contains("omit unused filters"));
1113        assert!(guidelines.contains("path: \"\""));
1114        assert!(guidelines.contains("git diff -- <path>"));
1115        assert!(guidelines.contains("build tools"));
1116        assert!(guidelines.contains("test tools"));
1117        // Search steering: with both tools present the browse line must
1118        // prefer `code_search` over `rg` for code (session evidence showed
1119        // `rg`-via-exec crowding out `code_search` 654:15).
1120        assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
1121        // Latency steering: fast checks before full builds (tool-agnostic).
1122        assert!(guidelines.contains("Run fast checks before full builds"));
1123        // Completion-as-checkpoint guidance lives in the operating profiles;
1124        // the guidelines section no longer repeats it.
1125        assert!(!guidelines.contains("Completion is a checkpoint"));
1126    }
1127
1128    #[test]
1129    fn test_edit_workflow_guidance() {
1130        let tools = vec![TOOL_APPLY_PATCH.to_string()];
1131        let guidelines = guidelines_for(&tools, None);
1132        assert!(guidelines.contains("Use `apply_patch`"));
1133        assert!(guidelines.contains("patches small"));
1134        // Completion-as-checkpoint guidance lives in the operating profiles;
1135        // the guidelines section no longer repeats it.
1136        assert!(!guidelines.contains("verification resolved"));
1137    }
1138
1139    #[test]
1140    fn test_vt_code_guidance_omits_task_tracker() {
1141        let tools = vec![
1142            TOOL_EXEC_COMMAND.to_string(),
1143            TOOL_WRITE_STDIN.to_string(),
1144            TOOL_APPLY_PATCH.to_string(),
1145        ];
1146        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1147
1148        assert!(guidelines.contains("exec_command.cmd"));
1149        for command in ["ls", "rg", "find", "cat", "sed", "awk"] {
1150            assert!(
1151                guidelines.contains(&format!("`{command}`")),
1152                "{command} should be shown as an exec_command.cmd example"
1153            );
1154        }
1155        assert!(guidelines.contains("`write_stdin`"));
1156        assert!(guidelines.contains("At most three live background processes"));
1157        assert!(guidelines.contains("`apply_patch`"));
1158        assert!(!guidelines.contains("task_tracker"));
1159        assert!(!guidelines.contains("list_files"));
1160        assert!(!guidelines.contains("read_file"));
1161    }
1162
1163    #[test]
1164    fn task_tracker_guidance_explains_action_aware_indices() {
1165        let guidelines = generate_runtime_tool_guidelines_for_profile(
1166            &[TOOL_TASK_TRACKER.to_string()],
1167            true,
1168            ResolvedShellPromptProfile::UnixLike,
1169        );
1170
1171        assert!(guidelines.contains(&format!("\n{PLANNING_TASK_TRACKER_INDEX_LINE}")));
1172        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("positive flat indices"));
1173        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("(index 0 is invalid while planning)"));
1174        assert!(PLANNING_TASK_TRACKER_INDEX_LINE.contains("full checklist replacement"));
1175        // The planning sidecar rejects index 0, so no planning line may present
1176        // it as a valid checklist-completion index.
1177        for line in [PLANNING_TASK_TRACKER_INDEX_LINE, PLANNING_TASK_TRACKER_COMPACT_LINE] {
1178            assert!(line.contains("index 0 is invalid while planning"), "{line}");
1179            assert!(!line.contains("reserved"), "{line}");
1180            assert!(!line.contains("index: 0"), "{line}");
1181        }
1182    }
1183
1184    #[test]
1185    fn unix_like_guidance_makes_command_reuse_explicit() {
1186        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_WRITE_STDIN.to_string()];
1187        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1188
1189        assert!(guidelines.contains("one-shot `exec_command` calls"));
1190        assert!(guidelines.contains("`!!`, `!$`, `!ssh`, or `fc`"));
1191        assert!(guidelines.contains("write full command arguments explicitly"));
1192        assert!(guidelines.contains("conversation or tool results"));
1193        assert!(guidelines.contains("existing `session_id`"));
1194        assert!(guidelines.contains("background: true"));
1195        assert!(guidelines.contains("Bash `histverify`"));
1196        assert!(guidelines.contains("zsh `HIST_VERIFY`"));
1197        // Cross-turn resume (invariant #22): the live id arrives via a
1198        // turn-start `Exec session resume:` hint when a prior turn ended mid-run.
1199        assert!(guidelines.contains("`Exec session resume:`"));
1200        assert!(guidelines.contains("prior turn ended mid-run"));
1201        // No `code_search` in this profile: the search-preference clause
1202        // must not spend budget naming an unavailable tool.
1203        assert!(!guidelines.contains("Prefer `code_search` over `rg`"));
1204    }
1205
1206    #[test]
1207    fn write_stdin_guidance_advertises_cross_turn_resume_hint() {
1208        let tools = vec![TOOL_WRITE_STDIN.to_string()];
1209        let default_guidance =
1210            generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1211        assert!(default_guidance.contains("`Exec session resume:`"));
1212        assert!(default_guidance.contains("prior turn ended mid-run"));
1213        assert!(default_guidance.contains("if missing, recover prior output"));
1214        assert!(default_guidance.contains("rerun only for fresh results"));
1215
1216        let minimal = generate_tool_guidelines_with_capabilities(
1217            &tools,
1218            None,
1219            ResolvedShellPromptProfile::UnixLike,
1220            ToolGuidanceProfile::Minimal,
1221            false,
1222        );
1223        assert!(minimal.contains("`Exec session resume:`"));
1224        assert!(minimal.contains("prior turn ended mid-run"));
1225        assert!(minimal.contains("if missing, recover prior output"));
1226    }
1227
1228    #[test]
1229    fn powershell_guidance_uses_native_command_examples() {
1230        let tools = vec![
1231            TOOL_EXEC_COMMAND.to_string(),
1232            TOOL_CODE_SEARCH.to_string(),
1233            TOOL_APPLY_PATCH.to_string(),
1234        ];
1235        let guidelines =
1236            generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::PowerShell, true);
1237
1238        assert!(guidelines.contains("native PowerShell commands"));
1239        assert!(guidelines.contains("`Get-ChildItem`"));
1240        assert!(guidelines.contains("`Select-String`"));
1241        assert!(guidelines.contains("native PowerShell syntax"));
1242        assert!(guidelines.contains("Prefer `code_search` over `Select-String` for code"));
1243        assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1244        assert!(guidelines.contains("literal smart-case"));
1245        assert!(guidelines.contains("omit unused filters"));
1246        assert!(!guidelines.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1247        assert!(!guidelines.contains("shell history expansion"));
1248        assert!(!guidelines.contains("histverify"));
1249        assert!(!guidelines.contains("HIST_VERIFY"));
1250    }
1251
1252    #[test]
1253    fn shell_profile_prompt_keeps_policy_and_syntax_separate() {
1254        let unix = render_shell_profile_guidance(ResolvedShellPromptProfile::UnixLike);
1255        assert!(unix.contains("Active shell profile: `unix_like`"));
1256        assert!(unix.contains("does not rewrite GNU flags for macOS BSD tools"));
1257        assert!(unix.contains("controls prompt examples and expected command syntax only"));
1258        assert!(unix.contains("does not translate GNU-to-BSD"));
1259
1260        let powershell = render_shell_profile_guidance(ResolvedShellPromptProfile::PowerShell);
1261        assert!(powershell.contains("Active shell profile: `powershell`"));
1262        assert!(powershell.contains("WSL"));
1263        assert!(powershell.contains("Unix-like workflows"));
1264        assert!(powershell.contains("PowerShell-to-Unix"));
1265    }
1266
1267    #[test]
1268    fn test_harness_browse_tool_guidance() {
1269        let tools = vec![TOOL_LIST_FILES.to_string(), TOOL_READ_FILE.to_string()];
1270        let guidelines = guidelines_for(&tools, None);
1271        assert!(guidelines.contains("available read-only repository tools"));
1272        assert!(guidelines.contains("bounded `read_file` ranges"));
1273        assert!(!guidelines.contains("list_files"));
1274        assert!(!guidelines.contains("offset"));
1275        assert!(!guidelines.contains("per_page"));
1276    }
1277
1278    #[test]
1279    fn test_canonical_browse_tool_guidance_prefers_public_tools() {
1280        let tools = vec![
1281            TOOL_CODE_SEARCH.to_string(),
1282            TOOL_LIST_FILES.to_string(),
1283            "read_file".to_string(),
1284        ];
1285        let guidelines = guidelines_for(&tools, None);
1286        assert!(guidelines.contains("available read-only repository tools"));
1287        assert!(guidelines.contains("code_search"));
1288        assert!(guidelines.contains("bounded `read_file` ranges"));
1289    }
1290
1291    #[test]
1292    fn test_capability_basic_guidance() {
1293        let tools = vec![];
1294        let guidelines = guidelines_for(&tools, Some(CapabilityLevel::Basic));
1295        assert!(guidelines.contains("Capabilities: limited"));
1296        assert!(guidelines.contains("enable more capabilities"));
1297    }
1298
1299    #[test]
1300    fn test_capability_file_reading_guidance() {
1301        let tools = vec![TOOL_APPLY_PATCH.to_string()];
1302        let guidelines = guidelines_for(&tools, Some(CapabilityLevel::FileReading));
1303        assert!(guidelines.contains("Capabilities: read-only"));
1304        assert!(guidelines.contains("do not modify"));
1305    }
1306
1307    #[test]
1308    fn test_full_capabilities_no_special_guidance() {
1309        let tools = vec![
1310            TOOL_APPLY_PATCH.to_string(),
1311            TOOL_EXEC_COMMAND.to_string(),
1312            TOOL_CODE_SEARCH.to_string(),
1313        ];
1314        let guidelines = generate_tool_guidelines_for_profile(
1315            &tools,
1316            Some(CapabilityLevel::Editing),
1317            ResolvedShellPromptProfile::UnixLike,
1318            true,
1319        );
1320
1321        assert!(!guidelines.contains("Capabilities: limited"));
1322        assert!(!guidelines.contains("Capabilities: read-only"));
1323    }
1324
1325    #[test]
1326    fn test_empty_tools_shows_read_only_capabilities() {
1327        let tools = vec![];
1328        let guidelines = guidelines_for(&tools, None);
1329        assert!(guidelines.contains("Capabilities: read-only"));
1330    }
1331
1332    #[test]
1333    fn test_planning_workflow_guidance_keeps_verification_open() {
1334        let tools = vec![
1335            TOOL_EXEC_COMMAND.to_string(),
1336            TOOL_TASK_TRACKER.to_string(),
1337            TOOL_CODE_SEARCH.to_string(),
1338        ];
1339        let guidelines =
1340            generate_runtime_tool_guidelines_for_profile(&tools, true, ResolvedShellPromptProfile::UnixLike);
1341        assert!(guidelines.contains("Keep `task_tracker` updated"));
1342        assert!(guidelines.contains("blockers and verification open"));
1343    }
1344
1345    #[test]
1346    fn test_capability_inference_precedence() {
1347        let tools = vec![TOOL_APPLY_PATCH.to_string(), TOOL_CODE_SEARCH.to_string()];
1348        assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
1349
1350        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_APPLY_PATCH.to_string()];
1351        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
1352    }
1353
1354    #[test]
1355    fn test_capability_inference_variants() {
1356        let tools = vec![TOOL_APPLY_PATCH.to_string()];
1357        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
1358
1359        let tools = vec![TOOL_EXEC_COMMAND.to_string()];
1360        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Bash);
1361
1362        let tools = vec![TOOL_CODE_SEARCH.to_string()];
1363        assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
1364
1365        let tools = vec![TOOL_LIST_FILES.to_string()];
1366        assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileListing);
1367
1368        let tools = vec!["read_file".to_string()];
1369        assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileReading);
1370
1371        let tools = vec!["unknown_tool".to_string()];
1372        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Basic);
1373    }
1374
1375    #[test]
1376    fn test_guidelines_stay_compact() {
1377        let tools = vec![
1378            TOOL_EXEC_COMMAND.to_string(),
1379            TOOL_CODE_SEARCH.to_string(),
1380            "read_file".to_string(),
1381            TOOL_LIST_FILES.to_string(),
1382            "apply_patch".to_string(),
1383        ];
1384        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1385        assert!(guidelines.contains("Batch independent read-only calls"));
1386        assert!(guidelines.contains("code_search"));
1387        // Exec-capable profiles describe the fail-closed verifier pipeline
1388        // contract, including the terminal outcome and rejected shell forms.
1389        assert!(guidelines.contains("static read-only filtering pipelines use fail-closed `pipefail`"));
1390        assert!(guidelines.contains("Only terminal exit 0 clears verification"));
1391        assert!(guidelines.contains("dynamic syntax, mutating tails, `;`, and `||` do not qualify"));
1392        assert!(!guidelines.contains("elided"));
1393        assert!(guidelines.contains("max_output_tokens"));
1394        assert!(guidelines.contains("Build and Auto share tools and safety gates"));
1395        let approx_tokens = vtcode_commons::estimate_tokens(&guidelines);
1396        // The batching, bounded-diff, and verifier-discipline guardrails are
1397        // intentionally part of the compact shared prompt. Raised from 500 so
1398        // the verifier rule can state its reason (a visible exit status).
1399        // Raised to 580 for the Sonnet 5.5 low-effort real-check rule
1400        // (syntax-only/failed-to-start do not count; project package manager,
1401        // never sudo; state which check was skipped and why).
1402        assert!(approx_tokens < 580, "got ~{approx_tokens} tokens");
1403    }
1404
1405    #[test]
1406    fn deferred_tools_section_caps_groups_and_truncates_descriptions() {
1407        use crate::llm::provider::ToolNamespace;
1408
1409        fn deferred_mcp_tool(server: &str, tool: &str, description: &str) -> ToolDefinition {
1410            let mut definition = ToolDefinition::function(
1411                format!("mcp__{server}__{tool}"),
1412                "deferred".to_string(),
1413                serde_json::json!({"type": "object"}),
1414            );
1415            definition.namespace = Some(ToolNamespace {
1416                name: server.to_string(),
1417                description: description.to_string(),
1418            });
1419            definition.defer_loading = Some(true);
1420            definition
1421        }
1422
1423        let mut tools = Vec::new();
1424        for index in 0..10 {
1425            tools.push(deferred_mcp_tool(&format!("server-{index:02}"), "search", "Tools provided by MCP server"));
1426        }
1427        // One group with an unbounded server-provided description.
1428        tools.push(deferred_mcp_tool("server-long", "search", &"d".repeat(500)));
1429
1430        let mut prompt = "Base prompt".to_string();
1431        append_deferred_tools_prompt_section(&mut prompt, &tools);
1432
1433        assert!(prompt.contains("[Deferred Tools]"));
1434        assert!(prompt.contains("server-00 (1 tools)"));
1435        assert!(prompt.contains("server-07 (1 tools)"));
1436        assert!(!prompt.contains("server-08 (1 tools)"), "groups past the cap must collapse into overflow");
1437        assert!(!prompt.contains("server-09 (1 tools)"));
1438        assert!(
1439            prompt.contains("(+3 more deferred groups available"),
1440            "10 named groups + 1 long group over the 8-group cap overflows by 3"
1441        );
1442        assert!(!prompt.contains(&"d".repeat(121)), "group descriptions must stay truncated");
1443        assert!(prompt.contains("Use `search_tools` to find a deferred capability"));
1444
1445        // Idempotent: re-appending replaces the section instead of duplicating it.
1446        append_deferred_tools_prompt_section(&mut prompt, &tools);
1447        assert_eq!(prompt.matches("[Deferred Tools]").count(), 1);
1448    }
1449
1450    #[test]
1451    fn deferred_tools_section_omits_empty_and_fully_loaded_groups() {
1452        let mut prompt = "Base prompt".to_string();
1453        append_deferred_tools_prompt_section(&mut prompt, &[]);
1454        assert!(!prompt.contains("[Deferred Tools]"));
1455
1456        let loaded = ToolDefinition::function(
1457            "exec_command".to_string(),
1458            "Shell".to_string(),
1459            serde_json::json!({"type": "object"}),
1460        );
1461        let mut prompt = "Base prompt".to_string();
1462        append_deferred_tools_prompt_section(&mut prompt, &[loaded]);
1463        assert!(!prompt.contains("[Deferred Tools]"));
1464    }
1465
1466    #[test]
1467    fn test_parallel_tool_call_guidance() {
1468        let tools = vec![
1469            TOOL_EXEC_COMMAND.to_string(),
1470            TOOL_CODE_SEARCH.to_string(),
1471            TOOL_APPLY_PATCH.to_string(),
1472        ];
1473        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1474        assert!(guidelines.contains("parallel"), "Should include parallel tool call guidance");
1475        assert!(guidelines.contains("inputs do not depend"), "Should mention independent inputs");
1476    }
1477
1478    #[test]
1479    fn test_read_only_batching_guidance_is_explicit() {
1480        let tools = vec![
1481            TOOL_CODE_SEARCH.to_string(),
1482            TOOL_READ_FILE.to_string(),
1483            TOOL_LIST_FILES.to_string(),
1484        ];
1485        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1486
1487        assert!(guidelines.contains("Batch independent read-only calls"));
1488        assert!(guidelines.contains("`read_file` ranges"));
1489        assert!(guidelines.contains("serialize mutations"));
1490        // No exec tools in this profile: the verifier truthful-status rule is
1491        // exec-conditional and must not spend budget here.
1492        assert!(!guidelines.contains("Only terminal exit 0 clears verification"));
1493    }
1494
1495    #[test]
1496    fn execution_agents_can_suggest_planning_for_demanding_tasks() {
1497        let tools = vec![TOOL_START_PLANNING.to_string(), TOOL_EXEC_COMMAND.to_string()];
1498        let guidelines = generate_tool_guidelines_for_profile(&tools, None, ResolvedShellPromptProfile::UnixLike, true);
1499
1500        assert_eq!(guidelines.matches(START_PLANNING_GUIDANCE_LINE).count(), 1);
1501        let minimal = generate_tool_guidelines_with_capabilities(
1502            &tools,
1503            None,
1504            ResolvedShellPromptProfile::UnixLike,
1505            ToolGuidanceProfile::Minimal,
1506            false,
1507        );
1508        assert_eq!(minimal.matches(START_PLANNING_GUIDANCE_LINE).count(), 1);
1509        // Without the tool, no profile mentions it.
1510        let without = generate_tool_guidelines_for_profile(
1511            &[TOOL_EXEC_COMMAND.to_string()],
1512            None,
1513            ResolvedShellPromptProfile::UnixLike,
1514            true,
1515        );
1516        assert!(!without.contains("start_planning"));
1517    }
1518
1519    #[test]
1520    fn markdown_validation_is_optional_in_planning_and_execution() {
1521        let tools = vec![TOOL_EXEC_COMMAND.to_string(), TOOL_READ_FILE.to_string()];
1522        for planning in [false, true] {
1523            let guidance =
1524                generate_runtime_tool_guidelines_for_profile(&tools, planning, ResolvedShellPromptProfile::UnixLike);
1525            assert_eq!(guidance.matches(OPTIONAL_MARKDOWN_VALIDATION_GUIDANCE).count(), 1);
1526            assert!(guidance.contains("report skipped"));
1527            assert!(guidance.contains("without installing tools"));
1528            assert!(guidance.contains("Lint errors remain failures"));
1529        }
1530    }
1531
1532    #[test]
1533    fn planning_workflow_runtime_guidance_keeps_exec_read_only() {
1534        let tools = vec![
1535            TOOL_APPLY_PATCH.to_string(),
1536            TOOL_EXEC_COMMAND.to_string(),
1537            TOOL_CODE_SEARCH.to_string(),
1538        ];
1539        let guidelines =
1540            generate_runtime_tool_guidelines_for_profile(&tools, true, ResolvedShellPromptProfile::UnixLike);
1541
1542        assert!(guidelines.contains("Planning workflow active"));
1543        assert!(guidelines.contains("`exec_command` only for read-only verification"));
1544        assert!(!guidelines.contains("<proposed_plan>"));
1545        assert!(!guidelines.contains("Every implementation step"));
1546        assert!(guidelines.contains("omit unused filters"));
1547        assert!(!guidelines.contains("Inspect before edit"));
1548    }
1549
1550    #[test]
1551    fn runtime_tool_guidance_uses_explicit_powershell_profile() {
1552        let tools = vec![
1553            TOOL_APPLY_PATCH.to_string(),
1554            TOOL_EXEC_COMMAND.to_string(),
1555            TOOL_CODE_SEARCH.to_string(),
1556        ];
1557        let guidelines =
1558            generate_runtime_tool_guidelines_for_profile(&tools, false, ResolvedShellPromptProfile::PowerShell);
1559
1560        assert!(guidelines.contains("native PowerShell commands"));
1561        assert!(guidelines.contains("`Get-ChildItem`"));
1562        assert!(guidelines.contains("`Select-String`"));
1563        assert!(guidelines.contains("native PowerShell syntax"));
1564        assert!(!guidelines.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1565    }
1566
1567    #[test]
1568    fn runtime_tool_guidance_uses_explicit_unix_like_profile() {
1569        let tools = vec![
1570            TOOL_APPLY_PATCH.to_string(),
1571            TOOL_EXEC_COMMAND.to_string(),
1572            TOOL_CODE_SEARCH.to_string(),
1573        ];
1574        let guidelines =
1575            generate_runtime_tool_guidelines_for_profile(&tools, false, ResolvedShellPromptProfile::UnixLike);
1576
1577        assert!(guidelines.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1578        assert!(guidelines.contains("Prefer `code_search` over `rg`/`grep` for code"));
1579        assert!(guidelines.contains("Advanced `code_search` takes `query`"));
1580        assert!(guidelines.contains("literal smart-case"));
1581        assert!(guidelines.contains("shell-only tasks"));
1582        assert!(!guidelines.contains("native PowerShell commands"));
1583        assert!(!guidelines.contains("`Get-ChildItem`"));
1584    }
1585
1586    #[test]
1587    fn runtime_tool_prompt_sections_use_explicit_profile_for_active_tools() {
1588        let mut powershell_prompt = "Base prompt".to_string();
1589        let mut unix_prompt = "Base prompt".to_string();
1590        let snapshot = SessionToolCatalogSnapshot::new(
1591            7,
1592            9,
1593            false,
1594            false,
1595            Some(std::sync::Arc::new(vec![
1596                ToolDefinition::function(
1597                    TOOL_EXEC_COMMAND.to_string(),
1598                    "Shell".to_string(),
1599                    serde_json::json!({"type": "object"}),
1600                ),
1601                ToolDefinition::function(
1602                    TOOL_CODE_SEARCH.to_string(),
1603                    "Bounded source search".to_string(),
1604                    serde_json::json!({"type": "object"}),
1605                ),
1606            ])),
1607            false,
1608        );
1609
1610        append_runtime_tool_prompt_sections_for_profile(
1611            &mut powershell_prompt,
1612            &snapshot,
1613            false,
1614            ResolvedShellPromptProfile::PowerShell,
1615        );
1616        append_runtime_tool_prompt_sections_for_profile(
1617            &mut unix_prompt,
1618            &snapshot,
1619            false,
1620            ResolvedShellPromptProfile::UnixLike,
1621        );
1622
1623        assert!(powershell_prompt.contains("## Active Tools"));
1624        assert!(powershell_prompt.contains("`Get-ChildItem`"));
1625        assert!(powershell_prompt.contains("`Select-String`"));
1626        assert!(!powershell_prompt.contains("`ls`, `rg`, `find`, `cat`, `sed`, and `awk`"));
1627
1628        assert!(unix_prompt.contains("## Active Tools"));
1629        assert!(unix_prompt.contains("`ls`, `find`, `cat`, `sed`, and `awk` for repository browsing"));
1630        assert!(unix_prompt.contains("Prefer `code_search` over `rg`/`grep` for code"));
1631        assert!(unix_prompt.contains("Advanced `code_search` takes `query`"));
1632        assert!(unix_prompt.contains("literal smart-case"));
1633        assert!(!unix_prompt.contains("`Get-ChildItem`"));
1634    }
1635
1636    #[test]
1637    fn runtime_tool_prompt_sections_include_catalog_metadata() {
1638        let mut prompt = "Base prompt".to_string();
1639        let snapshot = SessionToolCatalogSnapshot::new(
1640            7,
1641            9,
1642            true,
1643            false,
1644            Some(std::sync::Arc::new(vec![
1645                ToolDefinition::function(
1646                    TOOL_EXEC_COMMAND.to_string(),
1647                    "Search".to_string(),
1648                    serde_json::json!({"type": "object"}),
1649                ),
1650                ToolDefinition::function(
1651                    TOOL_APPLY_PATCH.to_string(),
1652                    "File".to_string(),
1653                    serde_json::json!({"type": "object"}),
1654                ),
1655            ])),
1656            false,
1657        );
1658
1659        append_runtime_tool_prompt_sections_for_profile(
1660            &mut prompt,
1661            &snapshot,
1662            true,
1663            ShellPromptProfile::Auto.resolve_for_current_platform(),
1664        );
1665
1666        assert!(prompt.contains("## Active Tools"));
1667        assert!(prompt.contains("[Runtime Tool Catalog]"));
1668        assert!(prompt.contains("catalog_tools: 2"));
1669        assert!(prompt.contains("currently_available_tools: exec_command, apply_patch"));
1670        assert!(prompt.contains("request_user_input_enabled: false"));
1671    }
1672
1673    #[test]
1674    fn runtime_tool_prompt_sections_replace_existing_runtime_sections() {
1675        let mut prompt = "Base prompt".to_string();
1676        let first = SessionToolCatalogSnapshot::new(
1677            1,
1678            2,
1679            false,
1680            false,
1681            Some(std::sync::Arc::new(vec![ToolDefinition::function(
1682                TOOL_EXEC_COMMAND.to_string(),
1683                "Search".to_string(),
1684                serde_json::json!({"type": "object"}),
1685            )])),
1686            false,
1687        );
1688        let second = SessionToolCatalogSnapshot::new(
1689            7,
1690            9,
1691            true,
1692            true,
1693            Some(std::sync::Arc::new(vec![ToolDefinition::function(
1694                TOOL_APPLY_PATCH.to_string(),
1695                "File".to_string(),
1696                serde_json::json!({"type": "object"}),
1697            )])),
1698            false,
1699        );
1700
1701        let shell = ShellPromptProfile::Auto.resolve_for_current_platform();
1702        append_runtime_tool_prompt_sections_for_profile(&mut prompt, &first, true, shell);
1703        append_runtime_tool_prompt_sections_for_profile(&mut prompt, &second, true, shell);
1704
1705        assert_eq!(prompt.matches("## Active Tools").count(), 1);
1706        assert_eq!(prompt.matches("[Runtime Tool Catalog]").count(), 1);
1707        assert!(prompt.contains("version: 7"));
1708        assert!(!prompt.contains("version: 1"));
1709        assert!(prompt.contains("request_user_input_enabled: true"));
1710        assert!(!prompt.contains("request_user_input_enabled: false"));
1711    }
1712}