Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions PolyPilot.Tests/InstructionRecommendationHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using PolyPilot.Models;

namespace PolyPilot.Tests;

public class InstructionRecommendationHelperTests
{
[Fact]
public void BuildPrompt_NoArguments_ContainsBasicSections()
{
var prompt = InstructionRecommendationHelper.BuildRecommendationPrompt(null);

Assert.Contains("Copilot Instructions", prompt);
Assert.Contains("Skills", prompt);
Assert.Contains("Agents", prompt);
Assert.Contains("copilot-instructions.md", prompt);
}

[Fact]
public void BuildPrompt_WithWorkingDirectory_IncludesDirectory()
{
var prompt = InstructionRecommendationHelper.BuildRecommendationPrompt("/home/user/myproject");

Assert.Contains("/home/user/myproject", prompt);
}

[Fact]
public void BuildPrompt_WithRepoName_IncludesRepoName()
{
var prompt = InstructionRecommendationHelper.BuildRecommendationPrompt(
"/home/user/myproject", repoName: "MyOrg/MyRepo");

Assert.Contains("MyOrg/MyRepo", prompt);
}

[Fact]
public void BuildPrompt_WithExistingSkills_ListsThem()
{
var skills = new List<(string Name, string Description)>
{
("build", "Build the project"),
("test", "Run tests")
};

var prompt = InstructionRecommendationHelper.BuildRecommendationPrompt(
"/project", existingSkills: skills);

Assert.Contains("Currently configured skills", prompt);
Assert.Contains("**build**", prompt);
Assert.Contains("Build the project", prompt);
Assert.Contains("**test**", prompt);
Assert.Contains("Run tests", prompt);
}

[Fact]
public void BuildPrompt_WithExistingAgents_ListsThem()
{
var agents = new List<(string Name, string Description)>
{
("reviewer", "Code review agent"),
("docs", "")
};

var prompt = InstructionRecommendationHelper.BuildRecommendationPrompt(
"/project", existingAgents: agents);

Assert.Contains("Currently configured agents", prompt);
Assert.Contains("**reviewer**", prompt);
Assert.Contains("Code review agent", prompt);
Assert.Contains("**docs**", prompt);
}

[Fact]
public void BuildPrompt_NoSkillsOrAgents_DoesNotListSections()
{
var prompt = InstructionRecommendationHelper.BuildRecommendationPrompt("/project");

Assert.DoesNotContain("Currently configured skills", prompt);
Assert.DoesNotContain("Currently configured agents", prompt);
}

[Fact]
public void BuildPrompt_EmptySkillsAndAgents_DoesNotListSections()
{
var skills = new List<(string Name, string Description)>();
var agents = new List<(string Name, string Description)>();

var prompt = InstructionRecommendationHelper.BuildRecommendationPrompt(
"/project", existingSkills: skills, existingAgents: agents);

Assert.DoesNotContain("Currently configured skills", prompt);
Assert.DoesNotContain("Currently configured agents", prompt);
}

[Fact]
public void BuildPrompt_SkillWithEmptyDescription_OmitsDescription()
{
var skills = new List<(string Name, string Description)>
{
("deploy", "")
};

var prompt = InstructionRecommendationHelper.BuildRecommendationPrompt(
"/project", existingSkills: skills);

Assert.Contains("**deploy**", prompt);
// Should not have ": " after the name when description is empty
Assert.DoesNotContain("**deploy**: ", prompt);
}

[Fact]
public void BuildPrompt_AllParameters_IncludesEverything()
{
var skills = new List<(string Name, string Description)> { ("build", "Build it") };
var agents = new List<(string Name, string Description)> { ("review", "Review code") };

var prompt = InstructionRecommendationHelper.BuildRecommendationPrompt(
"/home/user/repo",
existingSkills: skills,
existingAgents: agents,
repoName: "org/repo");

Assert.Contains("org/repo", prompt);
Assert.Contains("/home/user/repo", prompt);
Assert.Contains("Currently configured skills", prompt);
Assert.Contains("Currently configured agents", prompt);
Assert.Contains("file path", prompt);
}
}
1 change: 1 addition & 0 deletions PolyPilot.Tests/PolyPilot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<Compile Include="../PolyPilot/Models/FiestaModels.cs" Link="Shared/FiestaModels.cs" />
<Compile Include="../PolyPilot/Models/ModelHelper.cs" Link="Shared/ModelHelper.cs" />
<Compile Include="../PolyPilot/Services/NotificationMessageBuilder.cs" Link="Shared/NotificationMessageBuilder.cs" />
<Compile Include="../PolyPilot/Models/InstructionRecommendationHelper.cs" Link="Shared/InstructionRecommendationHelper.cs" />
</ItemGroup>

</Project>
5 changes: 5 additions & 0 deletions PolyPilot/Components/Layout/SessionListItem.razor
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@
</button>
<div class="menu-separator"></div>
}
<button class="menu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); OnRecommendInstructions.InvokeAsync(); }">
πŸ’‘ Recommend Instructions
</button>
<div class="menu-separator"></div>
<button class="menu-item destructive" @onclick="() => { OnCloseMenu.InvokeAsync(); OnClose.InvokeAsync(); }">
πŸ—‘ Close Session
</button>
Expand Down Expand Up @@ -141,6 +145,7 @@
[Parameter] public EventCallback OnCommitRename { get; set; }
[Parameter] public EventCallback OnToggleMenu { get; set; }
[Parameter] public EventCallback OnCloseMenu { get; set; }
[Parameter] public EventCallback OnRecommendInstructions { get; set; }

private async Task HandleRenameKeyDown(KeyboardEventArgs e)
{
Expand Down
6 changes: 5 additions & 1 deletion PolyPilot/Components/Layout/SessionSidebar.razor
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ else
<button class="group-menu-item" @onclick="() => { openGroupMenuId = null; createSessionFormRef?.ExpandForRepo(rId); }">
βž• New Session
</button>
<button class="group-menu-item" @onclick="() => { openGroupMenuId = null; CopilotService.RequestInstructionRecommendation(repoId: rId); }">
πŸ’‘ Recommend Instructions
</button>
<div class="group-menu-separator"></div>
<button class="group-menu-item destructive" @onclick="() => { openGroupMenuId = null; confirmRemoveRepoId = rId; }">
πŸ—‘ Remove Repo
Expand Down Expand Up @@ -287,7 +290,8 @@ else
OnStartRename="() => StartRename(sName)"
OnCommitRename="CommitRename"
OnToggleMenu="() => ToggleSessionMenu(sName)"
OnCloseMenu="() => { openMenuSession = null; }" />
OnCloseMenu="() => { openMenuSession = null; }"
OnRecommendInstructions="() => CopilotService.RequestInstructionRecommendation(sName)" />
}
}
}
Expand Down
79 changes: 79 additions & 0 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
@inject GitAutoUpdateService GitAutoUpdate
@inject QrScannerService QrScanner
@inject FiestaService FiestaService
@inject RepoManager RepoManager
@implements IAsyncDisposable

<div class="dashboard @(expandedSession != null ? "expanded-mode" : "")">
Expand Down Expand Up @@ -259,6 +260,7 @@
CopilotService.OnError += HandleError;
CopilotService.OnTurnStart += HandleTurnStart;
CopilotService.OnTurnEnd += HandleTurnEnd;
CopilotService.OnInstructionRecommendationRequested += HandleInstructionRecommendation;
FiestaService.OnStateChanged += HandleFiestaStateChanged;
FiestaService.OnHostTaskUpdate += HandleFiestaTaskUpdate;

Expand Down Expand Up @@ -1102,6 +1104,7 @@
"- `/status` β€” Show git status\n" +
"- `/mcp` β€” List MCP servers (enable/disable with `/mcp enable|disable <name>`)\n" +
"- `/plugin` β€” List installed plugins (enable/disable with `/plugin enable|disable <name>`)\n" +
"- `/instructions` β€” Recommend instruction, skill, and agent improvements\n" +
"- `!<command>` β€” Run a shell command"));
break;

Expand Down Expand Up @@ -1222,6 +1225,10 @@
HandlePluginCommand(session, arg);
break;

case "instructions":
await SendInstructionRecommendation(sessionName, null);
return;

default:
// Unknown command β€” pass through to the SDK as a regular prompt
_ = CopilotService.SendPromptAsync(sessionName, input);
Expand All @@ -1234,6 +1241,77 @@
await InvokeAsync(SafeRefreshAsync);
}

private void HandleInstructionRecommendation(string? sessionName, string? repoId)
{
_ = InvokeAsync(async () =>
{
await SendInstructionRecommendation(sessionName, repoId);
});
}

private async Task SendInstructionRecommendation(string? sessionName, string? repoId)
{
// Resolve the target session
string targetSession;
if (!string.IsNullOrEmpty(sessionName) && sessions.Any(s => s.Name == sessionName))
{
targetSession = sessionName;
}
else if (!string.IsNullOrEmpty(expandedSession))
{
targetSession = expandedSession;
}
else if (sessions.Count > 0)
{
targetSession = sessions[0].Name;
}
else
{
return; // No session available
}

var session = sessions.FirstOrDefault(s => s.Name == targetSession);
if (session == null) return;

// Determine working directory and repo name
string? workingDirectory = session.WorkingDirectory;
string? repoName = null;

if (!string.IsNullOrEmpty(repoId))
{
var repo = RepoManager.Repositories.FirstOrDefault(r => r.Id == repoId);
if (repo != null)
{
repoName = repo.Name;
// Try to find a worktree for this repo to use as working directory
var worktree = RepoManager.Worktrees.FirstOrDefault(w => w.RepoId == repoId);
if (worktree != null && !string.IsNullOrEmpty(worktree.Path))
workingDirectory = worktree.Path;
}
}

// Gather existing skills and agents
var skills = CopilotService.DiscoverAvailableSkills(workingDirectory)
.Select(s => (s.Name, s.Description)).ToList();
var agents = CopilotService.DiscoverAvailableAgents(workingDirectory)
.Select(a => (a.Name, a.Description)).ToList();

var prompt = InstructionRecommendationHelper.BuildRecommendationPrompt(
workingDirectory, skills, agents, repoName);

// Select and expand this session
CopilotService.SetActiveSession(targetSession);
expandedSession = targetSession;
_explicitlyCollapsed = false;

session.History.Add(ChatMessage.UserMessage("/instructions"));
session.MessageCount = session.History.Count;
_needsScrollToBottom = true;
await InvokeAsync(SafeRefreshAsync);

_ = CopilotService.SendPromptAsync(targetSession, prompt);
}

private async Task RunGitCommand(string sessionName, AgentSessionInfo session, string gitArgs)
{
var cwd = GetShellCwd(sessionName);
Expand Down Expand Up @@ -2216,6 +2294,7 @@
CopilotService.OnError -= HandleError;
CopilotService.OnTurnStart -= HandleTurnStart;
CopilotService.OnTurnEnd -= HandleTurnEnd;
CopilotService.OnInstructionRecommendationRequested -= HandleInstructionRecommendation;
FiestaService.OnStateChanged -= HandleFiestaStateChanged;
FiestaService.OnHostTaskUpdate -= HandleFiestaTaskUpdate;
foreach (var images in pendingImagesBySession.Values)
Expand Down
77 changes: 77 additions & 0 deletions PolyPilot/Models/InstructionRecommendationHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System.Text;

namespace PolyPilot.Models;

/// <summary>
/// Builds prompts for generating instruction recommendations (skills, agents, copilot-instructions).
/// </summary>
public static class InstructionRecommendationHelper
{
/// <summary>
/// Build a recommendation prompt that asks the AI to analyze the project and suggest
/// improvements to skills, agents, and copilot instruction files.
/// </summary>
/// <param name="workingDirectory">The project directory to analyze (may be null).</param>
/// <param name="existingSkills">Currently discovered skills.</param>
/// <param name="existingAgents">Currently discovered agents.</param>
/// <param name="repoName">Optional repository display name.</param>
public static string BuildRecommendationPrompt(
string? workingDirectory,
IReadOnlyList<(string Name, string Description)>? existingSkills = null,
IReadOnlyList<(string Name, string Description)>? existingAgents = null,
string? repoName = null)
{
var sb = new StringBuilder();

sb.AppendLine("Analyze this project and recommend improvements to the AI coding assistant configuration.");
sb.AppendLine();

if (!string.IsNullOrEmpty(repoName))
sb.AppendLine($"**Repository:** {repoName}");
if (!string.IsNullOrEmpty(workingDirectory))
sb.AppendLine($"**Working directory:** {workingDirectory}");

sb.AppendLine();
sb.AppendLine("Please examine the project structure, code patterns, build system, and development workflow, then provide specific recommendations for:");
sb.AppendLine();
sb.AppendLine("1. **Copilot Instructions** (`.github/copilot-instructions.md`) β€” Suggest project-specific instructions that would help the AI understand conventions, architecture patterns, preferred libraries, naming conventions, error handling patterns, and other project-specific knowledge.");
sb.AppendLine();
sb.AppendLine("2. **Skills** (`.github/skills/` or `.copilot/skills/`) β€” Recommend reusable skills (with SKILL.md frontmatter) for common project tasks like building, testing, deploying, or domain-specific operations.");
sb.AppendLine();
sb.AppendLine("3. **Agents** (`.github/agents/` or `.copilot/agents/`) β€” Suggest specialized agent definitions for recurring workflows like code review, documentation generation, refactoring, or project-specific automation.");

if (existingSkills != null && existingSkills.Count > 0)
{
sb.AppendLine();
sb.AppendLine("**Currently configured skills:**");
foreach (var skill in existingSkills)
{
sb.Append($"- **{skill.Name}**");
if (!string.IsNullOrEmpty(skill.Description))
sb.Append($": {skill.Description}");
sb.AppendLine();
}
}

if (existingAgents != null && existingAgents.Count > 0)
{
sb.AppendLine();
sb.AppendLine("**Currently configured agents:**");
foreach (var agent in existingAgents)
{
sb.Append($"- **{agent.Name}**");
if (!string.IsNullOrEmpty(agent.Description))
sb.Append($": {agent.Description}");
sb.AppendLine();
}
}

sb.AppendLine();
sb.AppendLine("For each recommendation, provide:");
sb.AppendLine("- The file path where it should be created or updated");
sb.AppendLine("- The complete content to add");
sb.AppendLine("- A brief explanation of why it would be valuable");

return sb.ToString();
}
}
10 changes: 10 additions & 0 deletions PolyPilot/Services/CopilotService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ public CopilotService(ChatDatabase chatDb, ServerManager serverManager, WsBridge
public event Action<string, SessionUsageInfo>? OnUsageInfoChanged; // sessionName, usageInfo
public event Action<string>? OnTurnStart; // sessionName
public event Action<string>? OnTurnEnd; // sessionName
public event Action<string?, string?>? OnInstructionRecommendationRequested; // sessionName, repoId

/// <summary>
/// Request instruction recommendations for a session or repository.
/// Fires OnInstructionRecommendationRequested so the Dashboard can handle it.
/// </summary>
public void RequestInstructionRecommendation(string? sessionName = null, string? repoId = null)
{
OnInstructionRecommendationRequested?.Invoke(sessionName, repoId);
}

private class SessionState
{
Expand Down