mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
feat: add structured prompt invocation backend
- parse prompt tools metadata and carry structured promptInvocation payloads - store prompt invocation metadata on trigger messages for replay-safe reruns - route prompt agents through per-turn plan or Copilot agent overrides - restrict prompt-scoped tools in sidecar session configuration - auto-rescan project customization files with debounced watchers - document the new customization watcher and prompt invocation flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+2
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -187,10 +187,22 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public string? ProjectInstructions { get; init; }
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public IReadOnlyList<ChatMessageDto> 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<string>? Tools { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CancelTurnCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string TargetRequestId { get; init; } = string.Empty;
|
||||
|
||||
@@ -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<string> 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<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IAsyncDisposable> _disposables = [];
|
||||
|
||||
internal CopilotAgentBundle(IReadOnlyList<AIAgent> 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<string>? allowedTools = NormalizeToolNames(promptInvocation?.Tools);
|
||||
if (allowedTools is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sessionConfig.AvailableTools = BuildAvailableTools(sessionConfig.AvailableTools, allowedTools);
|
||||
|
||||
if (sessionConfig.Tools is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<AIFunction> 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<CustomAgentConfig>? CreateCustomAgents(
|
||||
IReadOnlyList<RunTurnCustomAgentConfigDto>? customAgents)
|
||||
{
|
||||
@@ -180,6 +212,67 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
: null;
|
||||
}
|
||||
|
||||
private static List<string> BuildAvailableTools(
|
||||
ICollection<string>? existingAvailableTools,
|
||||
IReadOnlyList<string> allowedTools)
|
||||
{
|
||||
List<string> 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<string>? NormalizeToolNames(IReadOnlyList<string>? 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<string>? 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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<IEnumerable<AIFunction>>(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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<AppServiceEvents> {
|
||||
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<string, PendingApprovalHandle>();
|
||||
@@ -243,6 +261,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private projectGitRefreshTimer?: ReturnType<typeof setTimeout>;
|
||||
private periodicProjectGitRefreshTimer?: ReturnType<typeof setInterval>;
|
||||
private runningProjectGitRefresh?: Promise<void>;
|
||||
private customizationWatcherUpdateQueue = Promise.resolve();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -289,6 +308,8 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
) {
|
||||
await this.workspaceRepository.save(this.workspace);
|
||||
}
|
||||
|
||||
await this.syncProjectCustomizationWatchers(this.workspace);
|
||||
}
|
||||
|
||||
if (!this.didScheduleInitialProjectGitRefresh) {
|
||||
@@ -322,6 +343,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
}
|
||||
|
||||
async resetLocalWorkspace(): Promise<WorkspaceState> {
|
||||
this.projectCustomizationWatcher.dispose();
|
||||
await this.sidecar.dispose();
|
||||
|
||||
try {
|
||||
@@ -392,6 +415,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
workspace.selectedSessionId = undefined;
|
||||
}
|
||||
|
||||
await this.syncProjectCustomizationWatchers(workspace);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
@@ -480,6 +506,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
content: string,
|
||||
attachments?: ChatMessageAttachment[],
|
||||
messageMode?: MessageMode,
|
||||
promptInvocation?: ProjectPromptInvocation,
|
||||
): Promise<void> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
@@ -1009,7 +1037,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
);
|
||||
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<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
): Promise<void> {
|
||||
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<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async syncProjectCustomizationWatchers(workspace: WorkspaceState): Promise<void> {
|
||||
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<void> {
|
||||
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<void>): Promise<void> {
|
||||
const scheduledTask = this.customizationWatcherUpdateQueue.then(task, task);
|
||||
this.customizationWatcherUpdateQueue = scheduledTask.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return scheduledTask;
|
||||
}
|
||||
|
||||
private async syncProjectCustomization(project: ProjectRecord): Promise<boolean> {
|
||||
if (isScratchpadProject(project)) {
|
||||
if (!project.customization || this.equalProjectCustomizationState(project.customization, undefined)) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<SessionRecord> => {
|
||||
const normalizedSession: SessionRecord = {
|
||||
...session,
|
||||
messages: (session.messages ?? []).map(normalizeChatMessageRecord),
|
||||
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
|
||||
runs: normalizeSessionRunRecords(session.runs),
|
||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string[]>;
|
||||
|
||||
export class ProjectCustomizationWatcher {
|
||||
private readonly watchFactory: ProjectWatchFactory;
|
||||
private readonly resolveWatchPaths: ProjectWatchPathResolver;
|
||||
private readonly debounceMs: number;
|
||||
private readonly watchHandlesByProjectId = new Map<string, Map<string, ProjectWatchHandle>>();
|
||||
private readonly pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
constructor(
|
||||
private readonly onChange: (projectId: string) => void | Promise<void>,
|
||||
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<ProjectCustomizationWatchTarget>): Promise<void> {
|
||||
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<void> {
|
||||
const nextWatchPaths = new Set(await this.resolveWatchPaths(project.path));
|
||||
const currentWatchHandles = this.watchHandlesByProjectId.get(project.id) ?? new Map<string, ProjectWatchHandle>();
|
||||
|
||||
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<string[]> {
|
||||
const paths = new Set<string>([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<string[]> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ProjectInstructionFile | ProjectAgentProfile | ProjectPromptFile, 'sourcePath' | 'id'>,
|
||||
right: Pick<ProjectInstructionFile | ProjectAgentProfile | ProjectPromptFile, 'sourcePath' | 'id'>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, ReturnType<typeof mock>>();
|
||||
const listenersByPath = new Map<string, () => 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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user