diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 316bbad..a5d6c1b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -124,7 +124,7 @@ Projects are the container for context. There are two kinds: The scratchpad is modeled inside the same workspace system instead of as a separate subsystem. That keeps the UI and session model consistent while still allowing special rules for scratchpad behavior. Each scratchpad session receives its own working directory under the shared scratchpad root, so session-created files stay isolated from other scratchpad conversations. -Project-backed entries also persist scanned Copilot customization metadata discovered from repository files such as `.github/copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, `.claude/CLAUDE.md`, recursive `.github/instructions/**/*.instructions.md`, recursive `.github/agents/**/*.agent.md`, and recursive `.github/prompts/**/*.prompt.md`. The main process owns that scan step, strips Markdown front matter where supported, classifies instruction files by application mode (`always`, `file`, `task`, `manual`), and stores the normalized results on the project record so repo instructions and enabled custom agent profiles can participate in later run execution without turning the renderer into a filesystem crawler. +Project-backed entries also persist scanned Copilot customization metadata discovered from repository files such as `.github/copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, `.claude/CLAUDE.md`, recursive `.github/instructions/**/*.instructions.md`, recursive `.github/agents/**/*.agent.md`, and recursive `.github/prompts/**/*.prompt.md`. The main process owns that scan step, strips Markdown front matter where supported, classifies instruction files by application mode (`always`, `file`, `task`, `manual`), and stores the normalized results on the project record so repo instructions and enabled custom agent profiles can participate in later run execution without turning the renderer into a filesystem crawler. It also maintains lightweight filesystem watches over the project root plus existing `.github/` and `.claude/` subdirectories so customization changes can trigger debounced rescans and renderer updates without manual refreshes. For git-backed projects, the main process also owns background git refreshes, captures a structured pre-run working-tree snapshot on each run record, and persists a post-run git change summary after project-backed turns complete. It also owns all git write operations exposed by Aryx — selective discard, staging, commit, push/pull/fetch, and branch lifecycle actions — so the renderer never shells out directly or manipulates repository state on its own. @@ -235,7 +235,7 @@ The same boundary also supports server-scoped sidecar commands that do not requi For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook definitions from `.github/hooks/*.json` under the repository root. Those files are parsed and merged once per run bundle, then projected onto the SDK session hook delegates. Hook commands run synchronously in the sidecar through the platform shell, with stdin JSON payloads shaped to match Copilot CLI hook expectations as closely as the SDK allows. Hook failures are logged to stderr and treated as non-fatal diagnostics, while `preToolUse` hook outputs can still deny a tool call before Aryx falls back to its built-in approval policy. -The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from always-on repo instructions plus formatted file-scoped and task-scoped `.instructions.md` entries, while omitting manual-only instruction files from automatic injection. It also merges enabled discovered custom agent profiles into the primary pattern agent's Copilot configuration before sending the command across the stdio boundary. The sidecar then folds those project instructions into the final SDK system message alongside the agent's own instructions and runtime guidance. +The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from always-on repo instructions plus formatted file-scoped and task-scoped `.instructions.md` entries, while omitting manual-only instruction files from automatic injection. It also merges enabled discovered custom agent profiles into the primary pattern agent's Copilot configuration before sending the command across the stdio boundary. Prompt-file submissions can additionally attach a structured `promptInvocation` payload with prompt identity, resolved prompt body, optional `agent`, and optional `tools` metadata. The main process stores that prompt invocation metadata on the triggering user message so replay and regenerate flows can rebuild it, promotes `agent: plan` to a per-turn plan-mode override, and falls back to a lightweight transcript message instead of pasting the full prompt body into chat history. The sidecar then folds both the project instructions and prompt invocation into the final SDK system message, uses prompt agent metadata to override `SessionConfig.Agent`, and narrows available tools for that turn when prompt `tools` metadata is present. For handoff workflows, the sidecar now also enables Agent Framework JSON checkpointing backed by a per-turn filesystem store under local app data. Each saved checkpoint is surfaced to the main process, which pairs the durable Agent Framework checkpoint with an in-memory rollback snapshot of `session.messages` and the active run timeline events. If the sidecar child process exits unexpectedly during the same app lifetime, Aryx restores the latest snapshot, clears pending approval/user-input/MCP-auth state for that run, and retries the `run-turn` request once with `resumeFromCheckpoint`. Checkpoint directories are deleted after the turn completes, cancels, or fails. This recovery path is intentionally scoped to same-app sidecar restarts; full app-restart workflow rehydration would require durable rollback snapshots in addition to the Agent Framework checkpoint payloads. diff --git a/README.md b/README.md index a705ff2..1bdadab 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r | Mid-turn steering | Send follow-up messages while an agent is running — input is injected immediately | | Plan review & questions | Agents propose plans and ask clarifying questions before acting | | Run timeline | Structured history of tool calls, delegations, hooks, and context usage | -| Copilot customization | Auto-discovers repo instructions (`copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, `.instructions.md`), agent profiles, and prompt files from your repo | +| Copilot customization | Auto-discovers repo instructions (`copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, `.instructions.md`), agent profiles, and prompt files from your repo, then auto-rescans when those files change | | Model & effort tuning | Choose models, adjust reasoning effort, and set interaction modes per session | ### Developer tooling diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index b0ac114..69376bb 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -187,10 +187,22 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope public string? ProjectInstructions { get; init; } public PatternDefinitionDto Pattern { get; init; } = new(); public IReadOnlyList Messages { get; init; } = []; + public RunTurnPromptInvocationDto? PromptInvocation { get; init; } public RunTurnToolingConfigDto? Tooling { get; init; } public WorkflowCheckpointResumeDto? ResumeFromCheckpoint { get; init; } } +public sealed class RunTurnPromptInvocationDto +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string SourcePath { get; init; } = string.Empty; + public string ResolvedPrompt { get; init; } = string.Empty; + public string? Description { get; init; } + public string? Agent { get; init; } + public IReadOnlyList? Tools { get; init; } +} + public sealed class CancelTurnCommandDto : SidecarCommandEnvelope { public string TargetRequestId { get; init; } = string.Empty; diff --git a/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs b/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs index 7c7b962..e65e12a 100644 --- a/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs +++ b/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs @@ -10,10 +10,12 @@ internal static class AgentInstructionComposer int agentIndex, string workspaceKind = "project", string interactionMode = "interactive", - string? projectInstructions = null) + string? projectInstructions = null, + RunTurnPromptInvocationDto? promptInvocation = null) { string baseInstructions = agent.Instructions.Trim(); string repositoryInstructions = projectInstructions?.Trim() ?? string.Empty; + string promptInvocationInstructions = FormatPromptInvocation(promptInvocation); string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase) ? """ You are operating in scratchpad mode. @@ -48,10 +50,21 @@ internal static class AgentInstructionComposer Focus on refining the answer already in progress. """; - return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance); + return JoinInstructionBlocks( + baseInstructions, + repositoryInstructions, + promptInvocationInstructions, + workspaceGuidance, + planModeGuidance, + groupChatGuidance); } - return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance); + return JoinInstructionBlocks( + baseInstructions, + repositoryInstructions, + promptInvocationInstructions, + workspaceGuidance, + planModeGuidance); } private static string JoinInstructionBlocks(params string[] blocks) @@ -60,4 +73,47 @@ internal static class AgentInstructionComposer "\n\n", blocks.Where(block => !string.IsNullOrWhiteSpace(block)).Select(block => block.Trim())); } + + private static string FormatPromptInvocation(RunTurnPromptInvocationDto? promptInvocation) + { + string? resolvedPrompt = promptInvocation?.ResolvedPrompt?.Trim(); + if (string.IsNullOrWhiteSpace(resolvedPrompt)) + { + return string.Empty; + } + + List lines = + [ + "The current turn was started from a repository prompt file.", + "Treat the prompt body below as the task directive for this turn rather than as prior user chat history.", + $"Source: {promptInvocation!.SourcePath.Trim()}", + $"Name: {promptInvocation.Name.Trim()}" + ]; + + if (!string.IsNullOrWhiteSpace(promptInvocation.Description)) + { + lines.Add($"Description: {promptInvocation.Description.Trim()}"); + } + + if (!string.IsNullOrWhiteSpace(promptInvocation.Agent)) + { + lines.Add($"Agent: {promptInvocation.Agent.Trim()}"); + } + + if (promptInvocation.Tools is not null) + { + List toolNames = promptInvocation.Tools + .Where(tool => !string.IsNullOrWhiteSpace(tool)) + .Select(tool => tool.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + lines.Add(toolNames.Count > 0 + ? $"Tools: {string.Join(", ", toolNames)}" + : "Tools: none"); + } + + lines.Add("Prompt instructions:"); + lines.Add(resolvedPrompt); + return string.Join("\n", lines); + } } diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index 6c73a7a..3378345 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -11,6 +11,13 @@ namespace Aryx.AgentHost.Services; internal sealed class CopilotAgentBundle : IAsyncDisposable { + private static readonly string[] RequiredPromptTools = + [ + "ask_user", + "report_intent", + "task_complete" + ]; + private const string HandoffToolPrefix = "handoff_to_"; private readonly List _disposables = []; internal CopilotAgentBundle(IReadOnlyList agents, bool hasConfiguredHooks) @@ -62,6 +69,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable hookCommandRunner); ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools); + ApplyPromptInvocation(sessionConfig, command.PromptInvocation); AryxCopilotAgent agent = new( client, @@ -104,7 +112,8 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable agentIndex, command.WorkspaceKind, command.Mode, - command.ProjectInstructions), + command.ProjectInstructions, + command.PromptInvocation), }, WorkingDirectory = command.ProjectPath, OnPermissionRequest = onPermissionRequest, @@ -113,7 +122,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable OnEvent = onSessionEvent, Streaming = true, CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents), - Agent = NormalizeOptionalString(definition.Copilot?.Agent), + Agent = ResolveEffectiveAgent(definition.Copilot?.Agent, command.PromptInvocation), SkillDirectories = CreateStringList(definition.Copilot?.SkillDirectories), DisabledSkills = CreateStringList(definition.Copilot?.DisabledSkills), InfiniteSessions = CreateInfiniteSessions(definition.Copilot?.InfiniteSessions), @@ -136,6 +145,29 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable } } + internal static void ApplyPromptInvocation( + SessionConfig sessionConfig, + RunTurnPromptInvocationDto? promptInvocation) + { + IReadOnlyList? allowedTools = NormalizeToolNames(promptInvocation?.Tools); + if (allowedTools is null) + { + return; + } + + sessionConfig.AvailableTools = BuildAvailableTools(sessionConfig.AvailableTools, allowedTools); + + if (sessionConfig.Tools is null) + { + return; + } + + List filteredTools = sessionConfig.Tools + .Where(tool => IsAlwaysAllowedTool(tool.Name) || allowedTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase)) + .ToList(); + sessionConfig.Tools = filteredTools.Count > 0 ? filteredTools : null; + } + internal static List? CreateCustomAgents( IReadOnlyList? customAgents) { @@ -180,6 +212,67 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable : null; } + private static List BuildAvailableTools( + ICollection? existingAvailableTools, + IReadOnlyList allowedTools) + { + List availableTools = existingAvailableTools is { Count: > 0 } + ? existingAvailableTools + .Where(tool => allowedTools.Contains(tool, StringComparer.OrdinalIgnoreCase)) + .ToList() + : [.. allowedTools]; + + foreach (string requiredTool in RequiredPromptTools) + { + if (!availableTools.Contains(requiredTool, StringComparer.OrdinalIgnoreCase)) + { + availableTools.Add(requiredTool); + } + } + + return availableTools; + } + + private static bool IsAlwaysAllowedTool(string toolName) + { + return toolName.StartsWith(HandoffToolPrefix, StringComparison.Ordinal) + || RequiredPromptTools.Contains(toolName, StringComparer.OrdinalIgnoreCase); + } + + private static IReadOnlyList? NormalizeToolNames(IReadOnlyList? values) + { + if (values is null) + { + return null; + } + + return values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string? ResolveEffectiveAgent( + string? defaultAgent, + RunTurnPromptInvocationDto? promptInvocation) + { + string? promptAgent = NormalizeOptionalString(promptInvocation?.Agent); + if (!string.IsNullOrWhiteSpace(promptAgent) + && !string.Equals(promptAgent, "plan", StringComparison.OrdinalIgnoreCase)) + { + return promptAgent; + } + + IReadOnlyList? promptTools = NormalizeToolNames(promptInvocation?.Tools); + if (promptTools is { Count: > 0 }) + { + return "agent"; + } + + return NormalizeOptionalString(defaultAgent); + } + private static string? NormalizeOptionalString(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs index 335afcc..f02874d 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs @@ -184,6 +184,48 @@ public sealed class AgentInstructionComposerTests < instructions.IndexOf("scratchpad mode", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void Compose_AppendsPromptInvocationAsATaskDirective() + { + PatternDefinitionDto pattern = new() + { + Id = "pattern-single", + Name = "Single", + Mode = "single", + Availability = "available", + }; + PatternAgentDefinitionDto agent = CreateAgent( + id: "agent-primary", + name: "Primary Agent", + instructions: "You are a helpful assistant."); + + string instructions = AgentInstructionComposer.Compose( + pattern, + agent, + agentIndex: 0, + promptInvocation: new RunTurnPromptInvocationDto + { + Id = "project_customization_prompt_doc_review", + Name = "doc-review", + SourcePath = @".github\prompts\docs\doc-review.prompt.md", + Description = "Review docs for missing steps", + Agent = "plan", + Tools = ["view", "glob"], + ResolvedPrompt = "Review the docs for missing steps and propose updates." + }); + + Assert.Contains("repository prompt file", instructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains(@"Source: .github\prompts\docs\doc-review.prompt.md", instructions, StringComparison.Ordinal); + Assert.Contains("Name: doc-review", instructions, StringComparison.Ordinal); + Assert.Contains("Description: Review docs for missing steps", instructions, StringComparison.Ordinal); + Assert.Contains("Agent: plan", instructions, StringComparison.Ordinal); + Assert.Contains("Tools: view, glob", instructions, StringComparison.Ordinal); + Assert.Contains( + "Prompt instructions:\nReview the docs for missing steps and propose updates.", + instructions, + StringComparison.Ordinal); + } + private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions) { return new PatternAgentDefinitionDto diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs index fde4f3f..f9571d8 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -54,6 +54,34 @@ public sealed class CopilotAgentBundleTests Assert.Equal(["glob", "view"], sessionConfig.AvailableTools); } + [Fact] + public void ApplyPromptInvocation_RestrictsAvailableToolsAndKeepsHandoffTools() + { + SessionConfig sessionConfig = new() + { + AvailableTools = ["view", "glob", "edit"], + Tools = [CreateTool("view"), CreateTool("edit"), CreateTool("handoff_to_reviewer")], + }; + + CopilotAgentBundle.ApplyPromptInvocation( + sessionConfig, + new RunTurnPromptInvocationDto + { + Id = "project_customization_prompt_doc_review", + Name = "doc-review", + SourcePath = @".github\prompts\docs\doc-review.prompt.md", + ResolvedPrompt = "Review the docs for missing steps.", + Tools = ["view"], + }); + + Assert.Equal(["view", "ask_user", "report_intent", "task_complete"], sessionConfig.AvailableTools); + + AIFunction[] tools = Assert.IsAssignableFrom>(sessionConfig.Tools).ToArray(); + Assert.Equal(2, tools.Length); + Assert.Contains(tools, tool => tool.Name == "view"); + Assert.Contains(tools, tool => tool.Name == "handoff_to_reviewer"); + } + [Fact] public void Constructor_StoresWhetherHooksAreConfigured() { @@ -426,6 +454,95 @@ public sealed class CopilotAgentBundleTests Assert.Equal("Help.\n\nFollow repository guidance.", sessionConfig.SystemMessage?.Content); } + [Fact] + public void CreateSessionConfig_UsesPromptAgentOverride() + { + RunTurnCommandDto command = new() + { + SessionId = "session-1", + ProjectPath = @"C:\workspace\project", + WorkspaceKind = "project", + Mode = "interactive", + PromptInvocation = new RunTurnPromptInvocationDto + { + Id = "project_customization_prompt_doc_review", + Name = "doc-review", + SourcePath = @".github\prompts\docs\doc-review.prompt.md", + Agent = "designer", + ResolvedPrompt = "Review the docs for missing steps.", + }, + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "Pattern", + Mode = "single", + Availability = "available", + Agents = + [ + new PatternAgentDefinitionDto + { + Id = "agent-1", + Name = "Primary", + Model = "gpt-5.4", + Instructions = "Help.", + }, + ], + }, + }; + + SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig( + command, + command.Pattern.Agents[0], + agentIndex: 0); + + Assert.Equal("designer", sessionConfig.Agent); + Assert.Contains("Review the docs for missing steps.", sessionConfig.SystemMessage?.Content, StringComparison.Ordinal); + } + + [Fact] + public void CreateSessionConfig_DefaultsPromptToolInvocationsToAgentMode() + { + RunTurnCommandDto command = new() + { + SessionId = "session-1", + ProjectPath = @"C:\workspace\project", + WorkspaceKind = "project", + Mode = "interactive", + PromptInvocation = new RunTurnPromptInvocationDto + { + Id = "project_customization_prompt_doc_review", + Name = "doc-review", + SourcePath = @".github\prompts\docs\doc-review.prompt.md", + ResolvedPrompt = "Review the docs for missing steps.", + Tools = ["view"], + }, + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "Pattern", + Mode = "single", + Availability = "available", + Agents = + [ + new PatternAgentDefinitionDto + { + Id = "agent-1", + Name = "Primary", + Model = "gpt-5.4", + Instructions = "Help.", + }, + ], + }, + }; + + SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig( + command, + command.Pattern.Agents[0], + agentIndex: 0); + + Assert.Equal("agent", sessionConfig.Agent); + } + [Fact] public async Task CopilotSessionHooks_Create_UsesApprovalPolicyForPreToolUse() { @@ -484,7 +601,7 @@ public sealed class CopilotAgentBundleTests Assert.Equal("agent-ux", agentId); } - private static AIFunction CreateTool() + private static AIFunction CreateTool(string name = "echo") { ToolTarget target = new(); MethodInfo method = typeof(ToolTarget).GetMethod(nameof(ToolTarget.Echo)) @@ -495,7 +612,7 @@ public sealed class CopilotAgentBundleTests target, new AIFunctionFactoryOptions { - Name = "echo", + Name = name, Description = "Echo test tool", }); } diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 6985052..5a535f1 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -8,6 +8,7 @@ import type { AgentActivityEvent, ApprovalRequestedEvent, ExitPlanModeRequestedEvent, + InteractionMode, MessageMode, McpOauthRequiredEvent, MessageReclassifiedEvent, @@ -44,10 +45,12 @@ import { } from '@shared/domain/discoveredTooling'; import { listEnabledProjectAgentProfiles, + normalizeProjectPromptInvocation, normalizeProjectCustomizationState, resolveProjectInstructionsContent, setProjectAgentProfileEnabled, type ProjectAgentProfile, + type ProjectPromptInvocation, type ProjectCustomizationState, } from '@shared/domain/projectCustomization'; import { @@ -137,6 +140,7 @@ import { getScratchpadSessionPath } from '@main/persistence/appPaths'; import { SecretStore } from '@main/secrets/secretStore'; import { ConfigScannerRegistry } from '@main/services/configScanner'; import { ProjectCustomizationScanner } from '@main/services/customizationScanner'; +import { ProjectCustomizationWatcher } from '@main/services/projectCustomizationWatcher'; import { SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE, SidecarClient, @@ -199,6 +203,18 @@ function equalStringArrays(left?: readonly string[], right?: readonly string[]): return normalizedLeft.every((value, index) => value === normalizedRight[index]); } +function buildPromptInvocationFallbackContent(promptInvocation?: ProjectPromptInvocation): string | undefined { + if (!promptInvocation) { + return undefined; + } + + return `Run prompt file: ${promptInvocation.name}`; +} + +function isPlanPromptInvocation(promptInvocation?: ProjectPromptInvocation): boolean { + return promptInvocation?.agent?.trim().toLowerCase() === 'plan'; +} + function isSidecarStoppedBeforeCompletionError(error: unknown): error is Error { return error instanceof Error && error.message === SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE; } @@ -227,6 +243,8 @@ export class AryxAppService extends EventEmitter { private readonly gitService = new GitService(); private readonly configScanner = new ConfigScannerRegistry(); private readonly customizationScanner = new ProjectCustomizationScanner(); + private readonly projectCustomizationWatcher = new ProjectCustomizationWatcher((projectId) => + this.handleProjectCustomizationWatcherChange(projectId)); private readonly probeMcpServers = probeServers; private readonly ptyManager = new PtyManager(); private readonly pendingApprovalHandles = new Map(); @@ -243,6 +261,7 @@ export class AryxAppService extends EventEmitter { private projectGitRefreshTimer?: ReturnType; private periodicProjectGitRefreshTimer?: ReturnType; private runningProjectGitRefresh?: Promise; + private customizationWatcherUpdateQueue = Promise.resolve(); constructor() { super(); @@ -289,6 +308,8 @@ export class AryxAppService extends EventEmitter { ) { await this.workspaceRepository.save(this.workspace); } + + await this.syncProjectCustomizationWatchers(this.workspace); } if (!this.didScheduleInitialProjectGitRefresh) { @@ -322,6 +343,7 @@ export class AryxAppService extends EventEmitter { clearInterval(this.periodicProjectGitRefreshTimer); this.periodicProjectGitRefreshTimer = undefined; } + this.projectCustomizationWatcher.dispose(); this.ptyManager.dispose(); await this.sidecar.dispose(); void this.secretStore; @@ -356,6 +378,7 @@ export class AryxAppService extends EventEmitter { } async resetLocalWorkspace(): Promise { + this.projectCustomizationWatcher.dispose(); await this.sidecar.dispose(); try { @@ -392,6 +415,7 @@ export class AryxAppService extends EventEmitter { workspace.selectedProjectId = existing.id; const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, existing); await this.syncProjectCustomization(existing); + await this.syncProjectCustomizationWatchers(workspace); if (didSyncProjectTooling) { this.pruneUnavailableSessionToolingSelections(workspace); await this.pruneUnavailableApprovalTools(workspace); @@ -411,6 +435,7 @@ export class AryxAppService extends EventEmitter { workspace.selectedProjectId = project.id; await this.syncProjectDiscoveredTooling(workspace, project); await this.syncProjectCustomization(project); + await this.syncProjectCustomizationWatchers(workspace); return this.persistAndBroadcast(workspace); } @@ -434,6 +459,7 @@ export class AryxAppService extends EventEmitter { workspace.selectedSessionId = undefined; } + await this.syncProjectCustomizationWatchers(workspace); return this.persistAndBroadcast(workspace); } @@ -480,6 +506,7 @@ export class AryxAppService extends EventEmitter { const workspace = await this.loadWorkspace(); const project = this.requireProject(workspace, projectId); await this.syncProjectCustomization(project); + await this.syncProjectCustomizationWatchers(workspace); return this.persistAndBroadcast(workspace); } @@ -993,6 +1020,7 @@ export class AryxAppService extends EventEmitter { content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode, + promptInvocation?: ProjectPromptInvocation, ): Promise { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); @@ -1009,7 +1037,9 @@ export class AryxAppService extends EventEmitter { ); const projectInstructions = resolveProjectInstructionsContent(project.customization); - const preparedContent = prepareChatMessageContent(content); + const normalizedPromptInvocation = normalizeProjectPromptInvocation(promptInvocation); + const preparedContent = prepareChatMessageContent(content) + ?? buildPromptInvocationFallbackContent(normalizedPromptInvocation); if (!preparedContent) { return; } @@ -1024,6 +1054,7 @@ export class AryxAppService extends EventEmitter { content: preparedContent, createdAt: occurredAt, attachments: attachments?.length ? attachments : undefined, + promptInvocation: normalizedPromptInvocation, }); await this.runPreparedSessionTurn(workspace, session, project, effectivePattern, projectInstructions, { occurredAt, @@ -1390,6 +1421,10 @@ export class AryxAppService extends EventEmitter { ): Promise { const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project'; const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options; + const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId); + const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation) + ? 'plan' + : session.interactionMode ?? 'interactive'; const runWorkingDirectory = session.cwd ?? project.path; const preRunGitSnapshot = workspaceKind === 'project' ? await this.gitService.captureWorkingTreeSnapshot(runWorkingDirectory, occurredAt) @@ -1439,12 +1474,13 @@ export class AryxAppService extends EventEmitter { sessionId: session.id, projectPath: runWorkingDirectory, workspaceKind, - mode: session.interactionMode ?? 'interactive', + mode: interactionMode, messageMode, projectInstructions, pattern: effectivePattern, messages: session.messages, attachments: attachments?.length ? attachments : undefined, + promptInvocation, tooling: this.buildRunTurnToolingConfig(workspace, session), resumeFromCheckpoint, }); @@ -3002,6 +3038,52 @@ export class AryxAppService extends EventEmitter { return true; } + private async syncProjectCustomizationWatchers(workspace: WorkspaceState): Promise { + await this.projectCustomizationWatcher.syncProjects( + workspace.projects + .filter((project) => !isScratchpadProject(project)) + .map((project) => ({ + id: project.id, + path: project.path, + })), + ); + } + + private resolveRunTurnPromptInvocation( + session: SessionRecord, + triggerMessageId: string, + ): ProjectPromptInvocation | undefined { + const triggerMessage = session.messages.find((message) => message.id === triggerMessageId); + return normalizeProjectPromptInvocation(triggerMessage?.promptInvocation); + } + + private async handleProjectCustomizationWatcherChange(projectId: string): Promise { + await this.enqueueCustomizationWatcherUpdate(async () => { + const workspace = await this.loadWorkspace(); + const project = workspace.projects.find((candidate) => candidate.id === projectId); + await this.syncProjectCustomizationWatchers(workspace); + + if (!project || isScratchpadProject(project)) { + return; + } + + const didSyncProjectCustomization = await this.syncProjectCustomization(project); + await this.syncProjectCustomizationWatchers(workspace); + if (didSyncProjectCustomization) { + await this.persistAndBroadcast(workspace); + } + }); + } + + private enqueueCustomizationWatcherUpdate(task: () => Promise): Promise { + const scheduledTask = this.customizationWatcherUpdateQueue.then(task, task); + this.customizationWatcherUpdateQueue = scheduledTask.then( + () => undefined, + () => undefined, + ); + return scheduledTask; + } + private async syncProjectCustomization(project: ProjectRecord): Promise { if (isScratchpadProject(project)) { if (!project.customization || this.equalProjectCustomizationState(project.customization, undefined)) { diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 9e67e79..6f77dc5 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -201,7 +201,13 @@ export function registerIpcHandlers( service.editAndResendSessionMessage(input.sessionId, input.messageId, input.content, input.attachments), ); ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) => - service.sendSessionMessage(input.sessionId, input.content, input.attachments, input.messageMode), + service.sendSessionMessage( + input.sessionId, + input.content, + input.attachments, + input.messageMode, + input.promptInvocation, + ), ); ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) => service.cancelSessionTurn(input.sessionId), diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index c8d5573..7ef5f36 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -6,7 +6,11 @@ import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/proj import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling'; import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization'; import { normalizeSessionRunRecords } from '@shared/domain/runTimeline'; -import { normalizeSessionBranchOrigin, type SessionRecord } from '@shared/domain/session'; +import { + normalizeChatMessageRecord, + normalizeSessionBranchOrigin, + type SessionRecord, +} from '@shared/domain/session'; import { normalizeSessionToolingSelection, normalizeWorkspaceSettings, @@ -81,6 +85,7 @@ export class WorkspaceRepository { const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise => { const normalizedSession: SessionRecord = { ...session, + messages: (session.messages ?? []).map(normalizeChatMessageRecord), branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin), runs: normalizeSessionRunRecords(session.runs), tooling: normalizeSessionToolingSelection(session.tooling), diff --git a/src/main/services/customizationScanner.ts b/src/main/services/customizationScanner.ts index bf9c4d1..2987e35 100644 --- a/src/main/services/customizationScanner.ts +++ b/src/main/services/customizationScanner.ts @@ -169,6 +169,7 @@ export class ProjectCustomizationScanner { name: readOptionalString(parsedFile.attributes, ['name']) ?? basename(filePath, '.prompt.md'), description: readOptionalString(parsedFile.attributes, ['description']), agent: readOptionalString(parsedFile.attributes, ['agent']), + tools: readOptionalStringArray(parsedFile.attributes.tools), template, variables: extractPromptVariables(template), sourcePath, diff --git a/src/main/services/projectCustomizationWatcher.ts b/src/main/services/projectCustomizationWatcher.ts new file mode 100644 index 0000000..112091b --- /dev/null +++ b/src/main/services/projectCustomizationWatcher.ts @@ -0,0 +1,169 @@ +import { watch } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +export interface ProjectCustomizationWatchTarget { + id: string; + path: string; +} + +type ProjectWatchHandle = { + close(): void; +}; + +type ProjectWatchFactory = ( + directoryPath: string, + onChange: () => void, +) => ProjectWatchHandle; + +type ProjectWatchPathResolver = (projectPath: string) => Promise; + +export class ProjectCustomizationWatcher { + private readonly watchFactory: ProjectWatchFactory; + private readonly resolveWatchPaths: ProjectWatchPathResolver; + private readonly debounceMs: number; + private readonly watchHandlesByProjectId = new Map>(); + private readonly pendingTimers = new Map>(); + + constructor( + private readonly onChange: (projectId: string) => void | Promise, + options?: { + watchFactory?: ProjectWatchFactory; + resolveWatchPaths?: ProjectWatchPathResolver; + debounceMs?: number; + }, + ) { + this.watchFactory = options?.watchFactory ?? createProjectWatchHandle; + this.resolveWatchPaths = options?.resolveWatchPaths ?? collectProjectCustomizationWatchPaths; + this.debounceMs = options?.debounceMs ?? 250; + } + + async syncProjects(projects: ReadonlyArray): Promise { + const nextProjectsById = new Map(projects.map((project) => [project.id, project])); + + for (const projectId of this.watchHandlesByProjectId.keys()) { + if (!nextProjectsById.has(projectId)) { + this.unwatchProject(projectId); + } + } + + for (const project of projects) { + await this.syncProject(project); + } + } + + dispose(): void { + for (const projectId of this.watchHandlesByProjectId.keys()) { + this.unwatchProject(projectId); + } + } + + private async syncProject(project: ProjectCustomizationWatchTarget): Promise { + const nextWatchPaths = new Set(await this.resolveWatchPaths(project.path)); + const currentWatchHandles = this.watchHandlesByProjectId.get(project.id) ?? new Map(); + + for (const [watchPath, handle] of currentWatchHandles) { + if (nextWatchPaths.has(watchPath)) { + continue; + } + + handle.close(); + currentWatchHandles.delete(watchPath); + } + + for (const watchPath of nextWatchPaths) { + if (currentWatchHandles.has(watchPath)) { + continue; + } + + try { + currentWatchHandles.set(watchPath, this.watchFactory(watchPath, () => this.scheduleChange(project.id))); + } catch (error) { + console.warn(`[aryx customization] Failed to watch ${watchPath}:`, error); + } + } + + if (currentWatchHandles.size > 0) { + this.watchHandlesByProjectId.set(project.id, currentWatchHandles); + return; + } + + this.watchHandlesByProjectId.delete(project.id); + } + + private unwatchProject(projectId: string): void { + const timer = this.pendingTimers.get(projectId); + if (timer) { + clearTimeout(timer); + this.pendingTimers.delete(projectId); + } + + const watchHandles = this.watchHandlesByProjectId.get(projectId); + if (!watchHandles) { + return; + } + + for (const handle of watchHandles.values()) { + handle.close(); + } + + this.watchHandlesByProjectId.delete(projectId); + } + + private scheduleChange(projectId: string): void { + const existingTimer = this.pendingTimers.get(projectId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + this.pendingTimers.delete(projectId); + void Promise.resolve(this.onChange(projectId)).catch((error) => { + console.warn(`[aryx customization] Failed to process watcher update for ${projectId}:`, error); + }); + }, this.debounceMs); + timer.unref?.(); + this.pendingTimers.set(projectId, timer); + } +} + +export async function collectProjectCustomizationWatchPaths(projectPath: string): Promise { + const paths = new Set([projectPath]); + + for (const relativeRoot of ['.github', '.claude']) { + for (const directoryPath of await collectExistingDirectories(join(projectPath, relativeRoot))) { + paths.add(directoryPath); + } + } + + return [...paths].sort((left, right) => left.localeCompare(right)); +} + +async function collectExistingDirectories(rootPath: string): Promise { + try { + const directories = [rootPath]; + const entries = await readdir(rootPath, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!entry.isDirectory()) { + continue; + } + + directories.push(...await collectExistingDirectories(join(rootPath, entry.name))); + } + + return directories; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + + console.warn(`[aryx customization] Failed to enumerate watch paths under ${rootPath}:`, error); + return []; + } +} + +function createProjectWatchHandle(directoryPath: string, onChange: () => void): ProjectWatchHandle { + return watch(directoryPath, { persistent: false }, () => { + onChange(); + }); +} diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 7056258..77ffbca 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -20,6 +20,7 @@ import type { } from '@shared/domain/tooling'; import type { WorkspaceState } from '@shared/domain/workspace'; import type { ChatMessageAttachment } from '@shared/domain/attachment'; +import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization'; export interface CreateSessionInput { projectId: string; @@ -35,6 +36,7 @@ export interface SendSessionMessageInput { content: string; attachments?: ChatMessageAttachment[]; messageMode?: MessageMode; + promptInvocation?: ProjectPromptInvocation; } export interface CancelSessionTurnInput { diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index d9fbf03..ec328ec 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -3,6 +3,7 @@ import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/ap import type { ChatMessageRecord } from '@shared/domain/session'; import type { RuntimeToolDefinition } from '@shared/domain/tooling'; import type { ChatMessageAttachment } from '@shared/domain/attachment'; +import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization'; export interface SidecarModeCapability { available: boolean; @@ -90,6 +91,7 @@ export interface RunTurnCommand { pattern: PatternDefinition; messages: ChatMessageRecord[]; attachments?: ChatMessageAttachment[]; + promptInvocation?: ProjectPromptInvocation; tooling?: RunTurnToolingConfig; resumeFromCheckpoint?: WorkflowCheckpointResume; } diff --git a/src/shared/domain/projectCustomization.ts b/src/shared/domain/projectCustomization.ts index da28f05..1c0e4a2 100644 --- a/src/shared/domain/projectCustomization.ts +++ b/src/shared/domain/projectCustomization.ts @@ -37,11 +37,22 @@ export interface ProjectPromptFile { name: string; description?: string; agent?: string; + tools?: string[]; template: string; variables: ProjectPromptVariable[]; sourcePath: string; } +export interface ProjectPromptInvocation { + id: string; + name: string; + sourcePath: string; + resolvedPrompt: string; + description?: string; + agent?: string; + tools?: string[]; +} + export interface ProjectCustomizationState { instructions: ProjectInstructionFile[]; agentProfiles: ProjectAgentProfile[]; @@ -262,9 +273,54 @@ function normalizeProjectPromptFile(promptFile: ProjectPromptFile): ProjectPromp normalizedPromptFile.agent = agent; } + const tools = normalizeOptionalStringArray(promptFile.tools); + if (tools) { + normalizedPromptFile.tools = tools; + } + return normalizedPromptFile; } +export function normalizeProjectPromptInvocation( + promptInvocation?: ProjectPromptInvocation, +): ProjectPromptInvocation | undefined { + if (!promptInvocation) { + return undefined; + } + + const normalizedPromptInvocation: ProjectPromptInvocation = { + id: promptInvocation.id.trim(), + name: promptInvocation.name.trim(), + sourcePath: normalizePathLikeString(promptInvocation.sourcePath), + resolvedPrompt: promptInvocation.resolvedPrompt.trim(), + }; + + if ( + normalizedPromptInvocation.id.length === 0 + || normalizedPromptInvocation.name.length === 0 + || normalizedPromptInvocation.resolvedPrompt.length === 0 + ) { + return undefined; + } + + const description = normalizeOptionalString(promptInvocation.description); + if (description) { + normalizedPromptInvocation.description = description; + } + + const agent = normalizeOptionalString(promptInvocation.agent); + if (agent) { + normalizedPromptInvocation.agent = agent; + } + + const tools = normalizeOptionalStringArray(promptInvocation.tools); + if (tools) { + normalizedPromptInvocation.tools = tools; + } + + return normalizedPromptInvocation; +} + function compareProjectFiles( left: Pick, right: Pick, diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index 2585eb0..3928354 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -16,6 +16,10 @@ import type { PendingPlanReviewRecord } from '@shared/domain/planReview'; import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth'; import type { ChatMessageAttachment } from '@shared/domain/attachment'; import type { InteractionMode } from '@shared/contracts/sidecar'; +import { + normalizeProjectPromptInvocation, + type ProjectPromptInvocation, +} from '@shared/domain/projectCustomization'; export type ChatRole = 'system' | 'user' | 'assistant'; export type ChatMessageKind = 'response' | 'thinking'; @@ -38,6 +42,7 @@ export interface ChatMessageRecord { isPinned?: boolean; pending?: boolean; attachments?: ChatMessageAttachment[]; + promptInvocation?: ProjectPromptInvocation; } export interface SessionBranchOrigin { @@ -166,6 +171,20 @@ export function resolveSessionModelConfig( }; } +export function normalizeChatMessageRecord(message: ChatMessageRecord): ChatMessageRecord { + const normalizedMessage: ChatMessageRecord = { + ...message, + }; + const promptInvocation = normalizeProjectPromptInvocation(message.promptInvocation); + if (promptInvocation) { + normalizedMessage.promptInvocation = promptInvocation; + } else { + delete normalizedMessage.promptInvocation; + } + + return normalizedMessage; +} + export function applySessionModelConfig( pattern: PatternDefinition, session: SessionRecord, diff --git a/src/shared/domain/sessionLibrary.ts b/src/shared/domain/sessionLibrary.ts index d831e16..12a811f 100644 --- a/src/shared/domain/sessionLibrary.ts +++ b/src/shared/domain/sessionLibrary.ts @@ -1,6 +1,10 @@ import type { PatternDefinition } from '@shared/domain/pattern'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import type { ChatMessageAttachment } from '@shared/domain/attachment'; +import { + normalizeProjectPromptInvocation, + type ProjectPromptInvocation, +} from '@shared/domain/projectCustomization'; import { resolveSessionTitle, type ChatMessageRecord, @@ -175,11 +179,26 @@ function cloneAttachments(attachments?: ChatMessageAttachment[]): ChatMessageAtt return cloned && cloned.length > 0 ? cloned : undefined; } +function clonePromptInvocation( + promptInvocation?: ProjectPromptInvocation, +): ProjectPromptInvocation | undefined { + const cloned = normalizeProjectPromptInvocation(promptInvocation); + if (!cloned) { + return undefined; + } + + return { + ...cloned, + tools: cloned.tools ? [...cloned.tools] : undefined, + }; +} + function cloneChatMessageRecord(message: ChatMessageRecord): ChatMessageRecord { return { ...message, pending: false, attachments: cloneAttachments(message.attachments), + promptInvocation: clonePromptInvocation(message.promptInvocation), }; } @@ -386,6 +405,7 @@ export function editAndResendSessionRecord( editedMessage.content = content; editedMessage.attachments = cloneAttachments(attachments); + editedMessage.promptInvocation = undefined; return { ...createDerivedSessionRecord(session, sessionId, editedAt), diff --git a/tests/main/appServiceProjectCustomization.test.ts b/tests/main/appServiceProjectCustomization.test.ts index d864603..d575555 100644 --- a/tests/main/appServiceProjectCustomization.test.ts +++ b/tests/main/appServiceProjectCustomization.test.ts @@ -282,6 +282,68 @@ describe('AryxAppService project customization', () => { ); }); + test('sendSessionMessage carries structured prompt invocations and uses prompt agent plan mode', async () => { + const workspace = createWorkspaceSeed(); + const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single'); + if (!pattern) { + throw new Error('Expected a single-agent pattern in the workspace seed.'); + } + + const project = createProject(); + const session = createSession(project.id, pattern.id); + + workspace.projects = [project]; + workspace.sessions = [session]; + workspace.selectedProjectId = project.id; + workspace.selectedPatternId = pattern.id; + workspace.selectedSessionId = session.id; + + let command: RunTurnCommand | undefined; + const service = createService(workspace, pattern, { + captureRunTurn: (capturedCommand) => { + command = capturedCommand; + }, + }); + + await service.sendSessionMessage(session.id, '', undefined, undefined, { + id: 'project_customization_prompt_doc_review', + name: 'doc-review', + sourcePath: '.github\\prompts\\docs\\doc-review.prompt.md', + description: 'Review the docs for missing steps', + agent: 'plan', + tools: ['view', 'glob'], + resolvedPrompt: 'Review the docs for missing steps and propose updates.', + }); + + expect(session.messages.at(-1)).toMatchObject({ + id: expect.any(String), + role: 'user', + authorName: 'You', + content: 'Run prompt file: doc-review', + createdAt: expect.any(String), + promptInvocation: { + id: 'project_customization_prompt_doc_review', + name: 'doc-review', + sourcePath: '.github\\prompts\\docs\\doc-review.prompt.md', + description: 'Review the docs for missing steps', + agent: 'plan', + tools: ['view', 'glob'], + resolvedPrompt: 'Review the docs for missing steps and propose updates.', + }, + }); + expect(command?.mode).toBe('plan'); + expect(command?.promptInvocation).toEqual({ + id: 'project_customization_prompt_doc_review', + name: 'doc-review', + sourcePath: '.github\\prompts\\docs\\doc-review.prompt.md', + description: 'Review the docs for missing steps', + agent: 'plan', + tools: ['view', 'glob'], + resolvedPrompt: 'Review the docs for missing steps and propose updates.', + }); + expect(command?.messages.at(-1)?.content).toBe('Run prompt file: doc-review'); + }); + test('setProjectAgentProfileEnabled persists the updated enabled state', async () => { const workspace = createWorkspaceSeed(); const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single'); diff --git a/tests/main/customizationScanner.test.ts b/tests/main/customizationScanner.test.ts index 067d823..2f4f1da 100644 --- a/tests/main/customizationScanner.test.ts +++ b/tests/main/customizationScanner.test.ts @@ -99,6 +99,9 @@ Focus on repository documentation only. name: explain-selected-code agent: agent description: Generate a clear explanation +tools: + - view + - glob --- Explain the following code: \${input:code:Paste your code here} @@ -177,6 +180,7 @@ Audience: \${input:audience:Who is this for?} name: 'explain-selected-code', description: 'Generate a clear explanation', agent: 'agent', + tools: ['view', 'glob'], template: 'Explain the following code:\n${input:code:Paste your code here}\nAudience: ${input:audience:Who is this for?}', variables: [ { name: 'code', placeholder: 'Paste your code here' }, diff --git a/tests/main/projectCustomizationWatcher.test.ts b/tests/main/projectCustomizationWatcher.test.ts new file mode 100644 index 0000000..312b129 --- /dev/null +++ b/tests/main/projectCustomizationWatcher.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + collectProjectCustomizationWatchPaths, + ProjectCustomizationWatcher, +} from '@main/services/projectCustomizationWatcher'; + +const temporaryPaths: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function createTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'aryx-customization-watcher-')); + temporaryPaths.push(directory); + return directory; +} + +describe('ProjectCustomizationWatcher', () => { + test('collects the project root plus existing customization directories recursively', async () => { + const projectPath = await createTempDirectory(); + await mkdir(join(projectPath, '.claude', 'rules'), { recursive: true }); + await mkdir(join(projectPath, '.github', 'prompts', 'docs'), { recursive: true }); + + expect(await collectProjectCustomizationWatchPaths(projectPath)).toEqual([ + projectPath, + join(projectPath, '.claude'), + join(projectPath, '.claude', 'rules'), + join(projectPath, '.github'), + join(projectPath, '.github', 'prompts'), + join(projectPath, '.github', 'prompts', 'docs'), + ]); + }); + + test('debounces change notifications and closes watches when projects are removed', async () => { + const changeCalls: string[] = []; + const closeByPath = new Map>(); + const listenersByPath = new Map void>(); + const watcher = new ProjectCustomizationWatcher( + async (projectId) => { + changeCalls.push(projectId); + }, + { + debounceMs: 20, + resolveWatchPaths: async (projectPath) => [projectPath, `${projectPath}\\.github`], + watchFactory: (directoryPath, onChange) => { + const close = mock(() => undefined); + closeByPath.set(directoryPath, close); + listenersByPath.set(directoryPath, onChange); + return { close }; + }, + }, + ); + + await watcher.syncProjects([ + { + id: 'project-alpha', + path: 'C:\\workspace\\alpha', + }, + ]); + + listenersByPath.get('C:\\workspace\\alpha')?.(); + listenersByPath.get('C:\\workspace\\alpha\\.github')?.(); + + await new Promise((resolve) => setTimeout(resolve, 60)); + + expect(changeCalls).toEqual(['project-alpha']); + + await watcher.syncProjects([]); + + expect(closeByPath.get('C:\\workspace\\alpha')).toHaveBeenCalled(); + expect(closeByPath.get('C:\\workspace\\alpha\\.github')).toHaveBeenCalled(); + }); +}); diff --git a/tests/shared/sessionLibrary.test.ts b/tests/shared/sessionLibrary.test.ts index 4e7b2a0..ef72eae 100644 --- a/tests/shared/sessionLibrary.test.ts +++ b/tests/shared/sessionLibrary.test.ts @@ -124,42 +124,50 @@ describe('session library helpers', () => { }); test('duplicates sessions as idle unpinned copies', () => { - const session = duplicateSessionRecord( - createSession({ - status: 'error', - isPinned: true, - isArchived: true, - lastError: 'sidecar crashed', - approvalSettings: { - autoApprovedToolNames: ['git.status'], - }, - pendingApproval: { - id: 'approval-1', - kind: 'tool-call', + const sourceSession = createSession({ + status: 'error', + isPinned: true, + isArchived: true, + lastError: 'sidecar crashed', + approvalSettings: { + autoApprovedToolNames: ['git.status'], + }, + pendingApproval: { + id: 'approval-1', + kind: 'tool-call', + status: 'pending', + requestedAt: '2026-03-23T00:01:00.000Z', + title: 'Approve tool access', + }, + pendingApprovalQueue: [ + { + id: 'approval-2', + kind: 'final-response', status: 'pending', - requestedAt: '2026-03-23T00:01:00.000Z', - title: 'Approve tool access', + requestedAt: '2026-03-23T00:02:00.000Z', + title: 'Approve final response', }, - pendingApprovalQueue: [ - { - id: 'approval-2', - kind: 'final-response', - status: 'pending', - requestedAt: '2026-03-23T00:02:00.000Z', - title: 'Approve final response', + ], + messages: [ + { + id: 'msg-1', + role: 'assistant', + authorName: 'Reviewer', + content: 'Done.', + createdAt: '2026-03-23T00:00:00.000Z', + pending: true, + promptInvocation: { + id: 'project_customization_prompt_doc_review', + name: 'doc-review', + sourcePath: '.github\\prompts\\docs\\doc-review.prompt.md', + resolvedPrompt: 'Review the docs for missing steps.', + tools: ['view'], }, - ], - messages: [ - { - id: 'msg-1', - role: 'assistant', - authorName: 'Reviewer', - content: 'Done.', - createdAt: '2026-03-23T00:00:00.000Z', - pending: true, - }, - ], - }), + }, + ], + }); + const session = duplicateSessionRecord( + sourceSession, 'session-copy', '2026-03-23T00:07:00.000Z', ); @@ -176,6 +184,15 @@ describe('session library helpers', () => { updatedAt: '2026-03-23T00:07:00.000Z', }); expect(session.messages[0]?.pending).toBe(false); + expect(session.messages[0]?.promptInvocation).toEqual({ + id: 'project_customization_prompt_doc_review', + name: 'doc-review', + sourcePath: '.github\\prompts\\docs\\doc-review.prompt.md', + resolvedPrompt: 'Review the docs for missing steps.', + tools: ['view'], + }); + expect(session.messages[0]?.promptInvocation).not.toBe(sourceSession.messages[0]?.promptInvocation); + expect(session.messages[0]?.promptInvocation?.tools).not.toBe(sourceSession.messages[0]?.promptInvocation?.tools); expect(session.approvalSettings).toEqual({ autoApprovedToolNames: ['git.status'], }); @@ -486,6 +503,13 @@ describe('session library helpers', () => { authorName: 'You', content: 'Try a different approach.', createdAt: '2026-03-23T00:02:00.000Z', + promptInvocation: { + id: 'project_customization_prompt_alt_plan', + name: 'alt-plan', + sourcePath: '.github\\prompts\\alt-plan.prompt.md', + resolvedPrompt: 'Try a different approach focused on session state.', + tools: ['view', 'glob'], + }, attachments: [ { type: 'file', @@ -525,6 +549,7 @@ describe('session library helpers', () => { id: 'msg-3', role: 'user', content: 'Focus on session state only.', + promptInvocation: undefined, attachments: [ { type: 'file',