feat: add phase 3 prompt customization backend

- parse prompt model and argument-hint metadata and persist model on prompt invocations
- expand markdown-linked file context in scanned prompt and instruction bodies
- discover .claude/rules instructions and support Claude-style paths metadata
- discover customization roots up to the nearest parent repository and watch them for changes
- apply per-turn prompt model overrides before sidecar execution

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-02 12:21:59 +02:00
co-authored by Copilot
parent bcbdd2ef29
commit 6eadb36c10
17 changed files with 779 additions and 114 deletions
+2 -2
View File
@@ -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. 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.
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 `.claude/rules/**/*.md`, recursive `.github/agents/**/*.agent.md`, and recursive `.github/prompts/**/*.prompt.md`. The main process owns that scan step, walks parent directories up to the nearest `.git` root so nested workspace folders inherit repository-level customizations, strips Markdown front matter where supported, expands relative Markdown links into referenced file context, 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 each discovered customization 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. 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.
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` / `.claude/rules` 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`, optional `model`, 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, hydrates missing metadata from the scanned prompt definition, promotes `agent: plan` to a per-turn plan-mode override, applies per-turn prompt model overrides to the effective pattern before execution, 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.
+1 -1
View File
@@ -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, then auto-rescans when those files change |
| Copilot customization | Auto-discovers repo instructions (`copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, `.instructions.md`, `.claude/rules`), agent profiles, and prompt files across nested repo roots, expands Markdown-linked context, exposes prompt metadata (`tools`, `model`, `argument-hint`), then auto-rescans when those files change |
| Model & effort tuning | Choose models, adjust reasoning effort, and set interaction modes per session |
### Developer tooling
@@ -200,6 +200,7 @@ public sealed class RunTurnPromptInvocationDto
public string ResolvedPrompt { get; init; } = string.Empty;
public string? Description { get; init; }
public string? Agent { get; init; }
public string? Model { get; init; }
public IReadOnlyList<string>? Tools { get; init; }
}
@@ -100,6 +100,11 @@ internal static class AgentInstructionComposer
lines.Add($"Agent: {promptInvocation.Agent.Trim()}");
}
if (!string.IsNullOrWhiteSpace(promptInvocation.Model))
{
lines.Add($"Model: {promptInvocation.Model.Trim()}");
}
if (promptInvocation.Tools is not null)
{
List<string> toolNames = promptInvocation.Tools
@@ -210,6 +210,7 @@ public sealed class AgentInstructionComposerTests
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
Description = "Review docs for missing steps",
Agent = "plan",
Model = "Claude Sonnet 4.5",
Tools = ["view", "glob"],
ResolvedPrompt = "Review the docs for missing steps and propose updates."
});
@@ -219,6 +220,7 @@ public sealed class AgentInstructionComposerTests
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("Model: Claude Sonnet 4.5", instructions, StringComparison.Ordinal);
Assert.Contains("Tools: view, glob", instructions, StringComparison.Ordinal);
Assert.Contains(
"Prompt instructions:\nReview the docs for missing steps and propose updates.",
+78 -4
View File
@@ -25,6 +25,7 @@ import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
import {
buildAvailableModelCatalog,
findModel,
findModelByReference,
normalizePatternModels,
resolveReasoningEffort,
} from '@shared/domain/models';
@@ -50,6 +51,7 @@ import {
resolveProjectInstructionsContent,
setProjectAgentProfileEnabled,
type ProjectAgentProfile,
type ProjectPromptFile,
type ProjectPromptInvocation,
type ProjectCustomizationState,
} from '@shared/domain/projectCustomization';
@@ -211,6 +213,40 @@ function buildPromptInvocationFallbackContent(promptInvocation?: ProjectPromptIn
return `Run prompt file: ${promptInvocation.name}`;
}
function hydratePromptInvocationMetadata(
promptInvocation: ProjectPromptInvocation | undefined,
projectCustomization: ProjectCustomizationState | undefined,
): ProjectPromptInvocation | undefined {
if (!promptInvocation) {
return undefined;
}
const matchingPromptFile = findMatchingPromptFile(projectCustomization?.promptFiles, promptInvocation);
if (!matchingPromptFile) {
return promptInvocation;
}
return normalizeProjectPromptInvocation({
...promptInvocation,
description: promptInvocation.description ?? matchingPromptFile.description,
agent: promptInvocation.agent ?? matchingPromptFile.agent,
model: promptInvocation.model ?? matchingPromptFile.model,
tools: promptInvocation.tools ?? matchingPromptFile.tools,
});
}
function findMatchingPromptFile(
promptFiles: ReadonlyArray<ProjectPromptFile> | undefined,
promptInvocation: ProjectPromptInvocation,
): ProjectPromptFile | undefined {
if (!promptFiles || promptFiles.length === 0) {
return undefined;
}
return promptFiles.find((promptFile) => promptFile.id === promptInvocation.id)
?? promptFiles.find((promptFile) => promptFile.sourcePath === promptInvocation.sourcePath);
}
function isPlanPromptInvocation(promptInvocation?: ProjectPromptInvocation): boolean {
return promptInvocation?.agent?.trim().toLowerCase() === 'plan';
}
@@ -1037,7 +1073,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
);
const projectInstructions = resolveProjectInstructionsContent(project.customization);
const normalizedPromptInvocation = normalizeProjectPromptInvocation(promptInvocation);
const normalizedPromptInvocation = hydratePromptInvocationMetadata(
normalizeProjectPromptInvocation(promptInvocation),
project.customization,
);
const preparedContent = prepareChatMessageContent(content)
?? buildPromptInvocationFallbackContent(normalizedPromptInvocation);
if (!preparedContent) {
@@ -1422,6 +1461,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId);
const patternForTurn = await this.applyPromptInvocationToPattern(effectivePattern, promptInvocation);
const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation)
? 'plan'
: session.interactionMode ?? 'interactive';
@@ -1436,7 +1476,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
}
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
session.title = resolveSessionTitle(session, patternForTurn, session.messages);
session.status = 'running';
session.lastError = undefined;
session.pendingPlanReview = undefined;
@@ -1448,7 +1488,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
project,
workingDirectory: runWorkingDirectory,
workspaceKind,
pattern: effectivePattern,
pattern: patternForTurn,
triggerMessageId,
startedAt: occurredAt,
preRunGitSnapshot,
@@ -1477,7 +1517,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
mode: interactionMode,
messageMode,
projectInstructions,
pattern: effectivePattern,
pattern: patternForTurn,
messages: session.messages,
attachments: attachments?.length ? attachments : undefined,
promptInvocation,
@@ -2889,6 +2929,40 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
}
private async applyPromptInvocationToPattern(
pattern: PatternDefinition,
promptInvocation?: ProjectPromptInvocation,
): Promise<PatternDefinition> {
const requestedModel = promptInvocation?.model?.trim();
if (!requestedModel) {
return pattern;
}
const modelCatalog = await this.loadAvailableModelCatalog();
const resolvedModel = findModelByReference(requestedModel, modelCatalog);
const effectiveModelId = resolvedModel?.id ?? requestedModel;
let didChange = false;
const agents = pattern.agents.map((agent) => {
const reasoningEffort = resolvedModel
? resolveReasoningEffort(resolvedModel, agent.reasoningEffort)
: agent.reasoningEffort;
if (agent.model === effectiveModelId && agent.reasoningEffort === reasoningEffort) {
return agent;
}
didChange = true;
return {
...agent,
model: effectiveModelId,
reasoningEffort,
};
});
return didChange ? { ...pattern, agents } : pattern;
}
private applyProjectCustomizationToPattern(
pattern: PatternDefinition,
project: ProjectRecord,
+183 -100
View File
@@ -14,6 +14,8 @@ import {
type ProjectPromptVariable,
} from '@shared/domain/projectCustomization';
import { nowIso } from '@shared/utils/ids';
import { expandMarkdownFileLinks } from '@main/services/projectCustomizationLinkResolver';
import { resolveProjectCustomizationRoots } from '@main/services/projectCustomizationRoots';
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):([^}]+)\}/g;
@@ -23,9 +25,21 @@ export class ProjectCustomizationScanner {
current?: ProjectCustomizationState,
): Promise<ProjectCustomizationState> {
const previous = normalizeProjectCustomizationState(current);
const instructions = await this.scanInstructionFiles(projectPath, previous);
const agentProfiles = await this.scanAgentProfiles(projectPath, previous);
const promptFiles = await this.scanPromptFiles(projectPath, previous);
const customizationRoots = await resolveProjectCustomizationRoots(projectPath);
const allowedRootPath = customizationRoots.at(-1) ?? projectPath;
const instructions = await this.scanInstructionFiles(
projectPath,
customizationRoots,
allowedRootPath,
previous,
);
const agentProfiles = await this.scanAgentProfiles(projectPath, customizationRoots, previous);
const promptFiles = await this.scanPromptFiles(
projectPath,
customizationRoots,
allowedRootPath,
previous,
);
return mergeProjectCustomizationState(
previous,
@@ -40,31 +54,60 @@ export class ProjectCustomizationScanner {
private async scanInstructionFiles(
projectPath: string,
customizationRoots: ReadonlyArray<string>,
allowedRootPath: string,
previous: ProjectCustomizationState,
): Promise<ProjectInstructionFile[]> {
const previousByPath = new Map(previous.instructions.map((instruction) => [instruction.sourcePath, instruction]));
const sourcePaths = [
'.github\\copilot-instructions.md',
'AGENTS.md',
'CLAUDE.md',
'.claude\\CLAUDE.md',
] as const;
const instructions: ProjectInstructionFile[] = [];
for (const sourcePath of sourcePaths) {
const filePath = join(projectPath, ...sourcePath.split('\\'));
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, 'always');
if (instruction) {
instructions.push(instruction);
}
}
for (const customizationRoot of customizationRoots) {
const alwaysOnSourcePaths = [
'.github\\copilot-instructions.md',
'AGENTS.md',
'CLAUDE.md',
'.claude\\CLAUDE.md',
] as const;
const instructionFilePaths = await this.listProjectFiles(join(projectPath, '.github', 'instructions'), '.instructions.md');
for (const filePath of instructionFilePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath);
if (instruction) {
instructions.push(instruction);
for (const sourcePath of alwaysOnSourcePaths) {
const filePath = join(customizationRoot, ...sourcePath.split('\\'));
const normalizedSourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, normalizedSourcePath, previousByPath, {
applicationMode: 'always',
projectPath,
allowedRootPath,
});
if (instruction) {
instructions.push(instruction);
}
}
const instructionFilePaths = await this.listProjectFiles(
join(customizationRoot, '.github', 'instructions'),
'.instructions.md',
);
for (const filePath of instructionFilePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, {
projectPath,
allowedRootPath,
});
if (instruction) {
instructions.push(instruction);
}
}
const claudeRuleFilePaths = await this.listProjectFiles(join(customizationRoot, '.claude', 'rules'), '.md');
for (const filePath of claudeRuleFilePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, {
projectPath,
allowedRootPath,
usesClaudeRulePaths: true,
});
if (instruction) {
instructions.push(instruction);
}
}
}
@@ -73,55 +116,58 @@ export class ProjectCustomizationScanner {
private async scanAgentProfiles(
projectPath: string,
customizationRoots: ReadonlyArray<string>,
previous: ProjectCustomizationState,
): Promise<ProjectAgentProfile[]> {
const previousByPath = new Map(previous.agentProfiles.map((profile) => [profile.sourcePath, profile]));
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'agents'), '.agent.md');
const profiles: ProjectAgentProfile[] = [];
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
for (const customizationRoot of customizationRoots) {
const filePaths = await this.listProjectFiles(join(customizationRoot, '.github', 'agents'), '.agent.md');
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
}
continue;
}
continue;
}
if (contents.kind === 'missing') {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
if (contents.kind === 'missing') {
continue;
}
continue;
}
const name = readOptionalString(parsedFile.attributes, ['name'])
?? basename(filePath, '.agent.md');
const prompt = parsedFile.body.trim();
if (!name || !prompt) {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
}
continue;
}
profiles.push({
id: buildProjectCustomizationItemId('agent', sourcePath),
name,
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
description: readOptionalString(parsedFile.attributes, ['description']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
prompt,
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
sourcePath,
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
});
const name = readOptionalString(parsedFile.attributes, ['name'])
?? basename(filePath, '.agent.md');
const prompt = parsedFile.body.trim();
if (!name || !prompt) {
continue;
}
profiles.push({
id: buildProjectCustomizationItemId('agent', sourcePath),
name,
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
description: readOptionalString(parsedFile.attributes, ['description']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
prompt,
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
sourcePath,
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
});
}
}
return profiles;
@@ -129,51 +175,61 @@ export class ProjectCustomizationScanner {
private async scanPromptFiles(
projectPath: string,
customizationRoots: ReadonlyArray<string>,
allowedRootPath: string,
previous: ProjectCustomizationState,
): Promise<ProjectPromptFile[]> {
const previousByPath = new Map(previous.promptFiles.map((promptFile) => [promptFile.sourcePath, promptFile]));
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'prompts'), '.prompt.md');
const promptFiles: ProjectPromptFile[] = [];
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
for (const customizationRoot of customizationRoots) {
const filePaths = await this.listProjectFiles(join(customizationRoot, '.github', 'prompts'), '.prompt.md');
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
}
continue;
}
continue;
}
if (contents.kind === 'missing') {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
if (contents.kind === 'missing') {
continue;
}
continue;
}
const template = parsedFile.body.trim();
if (!template) {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
}
continue;
}
promptFiles.push({
id: buildProjectCustomizationItemId('prompt', sourcePath),
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,
});
const template = await expandMarkdownFileLinks(parsedFile.body, {
sourceFilePath: filePath,
projectPath,
allowedRootPath,
});
if (!template) {
continue;
}
promptFiles.push({
id: buildProjectCustomizationItemId('prompt', sourcePath),
name: readOptionalString(parsedFile.attributes, ['name']) ?? basename(filePath, '.prompt.md'),
description: readOptionalString(parsedFile.attributes, ['description']),
argumentHint: readOptionalString(parsedFile.attributes, ['argument-hint', 'argumentHint']),
agent: readOptionalString(parsedFile.attributes, ['agent']),
model: readOptionalString(parsedFile.attributes, ['model']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
template,
variables: extractPromptVariables(template),
sourcePath,
});
}
}
return promptFiles;
@@ -232,7 +288,12 @@ export class ProjectCustomizationScanner {
filePath: string,
sourcePath: string,
previousByPath: ReadonlyMap<string, ProjectInstructionFile>,
applicationMode?: ProjectInstructionApplicationMode,
options: {
applicationMode?: ProjectInstructionApplicationMode;
projectPath: string;
allowedRootPath: string;
usesClaudeRulePaths?: boolean;
},
): Promise<ProjectInstructionFile | undefined> {
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'missing') {
@@ -248,18 +309,22 @@ export class ProjectCustomizationScanner {
return previousByPath.get(sourcePath);
}
const content = parsedFile.body.trim();
const content = await expandMarkdownFileLinks(parsedFile.body, {
sourceFilePath: filePath,
projectPath: options.projectPath,
allowedRootPath: options.allowedRootPath,
});
if (!content) {
return undefined;
}
const description = readOptionalString(parsedFile.attributes, ['description']);
const applyTo = readOptionalString(parsedFile.attributes, ['applyTo']);
const applyTo = readInstructionApplyTo(parsedFile.attributes, options.usesClaudeRulePaths === true);
const instruction: ProjectInstructionFile = {
id: buildProjectCustomizationItemId('instruction', sourcePath),
sourcePath,
content,
applicationMode: applicationMode ?? resolveInstructionApplicationMode(applyTo, description),
applicationMode: options.applicationMode ?? resolveInstructionApplicationMode(applyTo, description),
};
const name = readOptionalString(parsedFile.attributes, ['name']);
@@ -368,6 +433,24 @@ function readOptionalString(
return undefined;
}
function readInstructionApplyTo(
record: Record<string, unknown>,
usesClaudeRulePaths: boolean,
): string | undefined {
const applyTo = readOptionalString(record, ['applyTo']);
const paths = readOptionalStringArray(record.paths);
if (paths && paths.length > 0) {
return paths.join(',');
}
if (applyTo) {
return applyTo;
}
return usesClaudeRulePaths ? '**' : undefined;
}
function resolveInstructionApplicationMode(
applyTo: string | undefined,
description: string | undefined,
@@ -0,0 +1,202 @@
import { readFile } from 'node:fs/promises';
import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
const markdownLinkPattern = /\[[^\]]+\]\(([^)]+)\)/g;
type MarkdownLinkResolutionContext = {
projectPath: string;
allowedRootPath: string;
sourceFilePath: string;
seenPaths: Set<string>;
ancestry: readonly string[];
};
export async function expandMarkdownFileLinks(
content: string,
options: {
projectPath: string;
allowedRootPath: string;
sourceFilePath: string;
},
): Promise<string> {
const trimmedContent = content.trim();
if (!trimmedContent) {
return trimmedContent;
}
return expandMarkdownFileLinksRecursive(trimmedContent, {
...options,
seenPaths: new Set<string>(),
ancestry: [options.sourceFilePath],
});
}
async function expandMarkdownFileLinksRecursive(
content: string,
context: MarkdownLinkResolutionContext,
): Promise<string> {
const referencedBlocks: string[] = [];
for (const linkTarget of collectLocalMarkdownLinkTargets(content)) {
const resolvedPath = resolveMarkdownLinkTarget(context.sourceFilePath, linkTarget);
if (!resolvedPath) {
continue;
}
if (!isPathInsideRoot(resolvedPath, context.allowedRootPath)) {
console.warn(
`[aryx customization] Ignoring linked file outside the allowed customization root: ${resolvedPath}`,
);
continue;
}
if (context.seenPaths.has(resolvedPath)) {
continue;
}
if (context.ancestry.includes(resolvedPath)) {
console.warn(`[aryx customization] Ignoring circular Markdown link reference to ${resolvedPath}.`);
continue;
}
const linkedContent = await readLinkedFile(resolvedPath);
if (linkedContent === undefined) {
continue;
}
context.seenPaths.add(resolvedPath);
const expandedLinkedContent = isMarkdownLikePath(resolvedPath)
? await expandMarkdownFileLinksRecursive(linkedContent, {
...context,
sourceFilePath: resolvedPath,
ancestry: [...context.ancestry, resolvedPath],
})
: linkedContent.trim();
referencedBlocks.push(
formatReferencedFileBlock(
toProjectSourcePath(context.projectPath, resolvedPath),
expandedLinkedContent,
),
);
}
if (referencedBlocks.length === 0) {
return content.trim();
}
return `${content.trim()}\n\nReferenced file context:\n\n${referencedBlocks.join('\n\n')}`.trim();
}
function collectLocalMarkdownLinkTargets(content: string): string[] {
const targets: string[] = [];
const seenTargets = new Set<string>();
let match: RegExpExecArray | null;
while ((match = markdownLinkPattern.exec(content))) {
const target = match[1]?.trim();
if (!target || seenTargets.has(target)) {
continue;
}
seenTargets.add(target);
targets.push(target);
}
markdownLinkPattern.lastIndex = 0;
return targets;
}
function resolveMarkdownLinkTarget(sourceFilePath: string, rawTarget: string): string | undefined {
const target = extractMarkdownLinkDestination(rawTarget);
if (!target) {
return undefined;
}
return resolve(dirname(sourceFilePath), target);
}
function extractMarkdownLinkDestination(rawTarget: string): string | undefined {
let target = rawTarget.trim();
if (!target) {
return undefined;
}
if (target.startsWith('<') && target.endsWith('>')) {
target = target.slice(1, -1).trim();
} else {
const whitespaceIndex = target.search(/\s/);
if (whitespaceIndex >= 0) {
target = target.slice(0, whitespaceIndex);
}
}
const hashIndex = target.indexOf('#');
if (hashIndex >= 0) {
target = target.slice(0, hashIndex);
}
if (!target) {
return undefined;
}
const normalizedTarget = target.toLowerCase();
if (
target.startsWith('#')
|| isAbsolute(target)
|| normalizedTarget.startsWith('http://')
|| normalizedTarget.startsWith('https://')
|| normalizedTarget.startsWith('mailto:')
|| normalizedTarget.startsWith('vscode:')
|| normalizedTarget.startsWith('command:')
|| normalizedTarget.startsWith('data:')
) {
return undefined;
}
return target;
}
async function readLinkedFile(filePath: string): Promise<string | undefined> {
try {
const content = await readFile(filePath, 'utf8');
if (content.includes('\0')) {
console.warn(`[aryx customization] Ignoring binary-linked file ${filePath}.`);
return undefined;
}
return content.trim();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
console.warn(`[aryx customization] Linked file not found: ${filePath}`);
return undefined;
}
console.warn(`[aryx customization] Failed to read linked file ${filePath}:`, error);
return undefined;
}
}
function formatReferencedFileBlock(sourcePath: string, content: string): string {
return [
`Source: ${sourcePath}`,
'Contents:',
content.trim() || '[empty file]',
].join('\n');
}
function isMarkdownLikePath(filePath: string): boolean {
const normalizedPath = filePath.toLowerCase();
return normalizedPath.endsWith('.md') || normalizedPath.endsWith('.markdown');
}
function isPathInsideRoot(filePath: string, rootPath: string): boolean {
const relativePath = relative(rootPath, filePath);
return relativePath.length === 0
|| (!relativePath.startsWith('..') && !isAbsolute(relativePath));
}
function toProjectSourcePath(projectPath: string, filePath: string): string {
const relativePath = relative(projectPath, filePath).trim();
return relativePath ? relativePath.replaceAll('/', '\\') : basename(filePath);
}
@@ -0,0 +1,38 @@
import { access } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export async function resolveProjectCustomizationRoots(projectPath: string): Promise<string[]> {
if (await hasGitEntry(projectPath)) {
return [projectPath];
}
const ancestorPaths: string[] = [];
let currentPath = projectPath;
while (true) {
const parentPath = dirname(currentPath);
if (parentPath === currentPath) {
return [projectPath];
}
ancestorPaths.push(parentPath);
if (await hasGitEntry(parentPath)) {
return [projectPath, ...ancestorPaths];
}
currentPath = parentPath;
}
}
async function hasGitEntry(directoryPath: string): Promise<boolean> {
try {
await access(join(directoryPath, '.git'));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
console.warn(`[aryx customization] Failed to inspect ${join(directoryPath, '.git')}:`, error);
return false;
}
}
@@ -2,6 +2,8 @@ import { watch } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { resolveProjectCustomizationRoots } from '@main/services/projectCustomizationRoots';
export interface ProjectCustomizationWatchTarget {
id: string;
path: string;
@@ -128,11 +130,15 @@ export class ProjectCustomizationWatcher {
}
export async function collectProjectCustomizationWatchPaths(projectPath: string): Promise<string[]> {
const paths = new Set<string>([projectPath]);
const paths = new Set<string>();
for (const relativeRoot of ['.github', '.claude']) {
for (const directoryPath of await collectExistingDirectories(join(projectPath, relativeRoot))) {
paths.add(directoryPath);
for (const customizationRoot of await resolveProjectCustomizationRoots(projectPath)) {
paths.add(customizationRoot);
for (const relativeRoot of ['.github', '.claude']) {
for (const directoryPath of await collectExistingDirectories(join(customizationRoot, relativeRoot))) {
paths.add(directoryPath);
}
}
}
+14
View File
@@ -178,6 +178,20 @@ export function findModel(
return models.find((model) => model.id === id);
}
export function findModelByReference(
reference: string,
models: ReadonlyArray<ModelDefinition> = modelCatalog,
): ModelDefinition | undefined {
const trimmedReference = reference.trim();
if (!trimmedReference) {
return undefined;
}
return models.find((model) =>
model.id === trimmedReference
|| model.name.localeCompare(trimmedReference, undefined, { sensitivity: 'accent' }) === 0);
}
export function inferProvider(modelId: string): ModelProvider | undefined {
if (modelId.startsWith('gpt-')) return 'openai';
if (modelId.startsWith('claude-')) return 'anthropic';
+18
View File
@@ -36,7 +36,9 @@ export interface ProjectPromptFile {
id: string;
name: string;
description?: string;
argumentHint?: string;
agent?: string;
model?: string;
tools?: string[];
template: string;
variables: ProjectPromptVariable[];
@@ -50,6 +52,7 @@ export interface ProjectPromptInvocation {
resolvedPrompt: string;
description?: string;
agent?: string;
model?: string;
tools?: string[];
}
@@ -268,11 +271,21 @@ function normalizeProjectPromptFile(promptFile: ProjectPromptFile): ProjectPromp
normalizedPromptFile.description = description;
}
const argumentHint = normalizeOptionalString(promptFile.argumentHint);
if (argumentHint) {
normalizedPromptFile.argumentHint = argumentHint;
}
const agent = normalizeOptionalString(promptFile.agent);
if (agent) {
normalizedPromptFile.agent = agent;
}
const model = normalizeOptionalString(promptFile.model);
if (model) {
normalizedPromptFile.model = model;
}
const tools = normalizeOptionalStringArray(promptFile.tools);
if (tools) {
normalizedPromptFile.tools = tools;
@@ -313,6 +326,11 @@ export function normalizeProjectPromptInvocation(
normalizedPromptInvocation.agent = agent;
}
const model = normalizeOptionalString(promptInvocation.model);
if (model) {
normalizedPromptInvocation.model = model;
}
const tools = normalizeOptionalStringArray(promptInvocation.tools);
if (tools) {
normalizedPromptInvocation.tools = tools;
@@ -1,6 +1,7 @@
import { describe, expect, mock, test } from 'bun:test';
import type { RunTurnCommand } from '@shared/contracts/sidecar';
import { buildAvailableModelCatalog } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
@@ -76,6 +77,7 @@ function createService(
internals.loadWorkspace = async () => workspace;
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
internals.buildEffectivePattern = async () => pattern;
internals.loadAvailableModelCatalog = async () => buildAvailableModelCatalog();
internals.awaitFinalResponseApproval = async () => undefined;
internals.finalizeTurn = () => undefined;
internals.emitSessionEvent = () => undefined;
@@ -344,6 +346,84 @@ describe('AryxAppService project customization', () => {
expect(command?.messages.at(-1)?.content).toBe('Run prompt file: doc-review');
});
test('sendSessionMessage hydrates prompt model metadata and overrides the turn pattern model', async () => {
const workspace = createWorkspaceSeed();
const basePattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!basePattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
}
const pattern: PatternDefinition = {
...basePattern,
agents: [
{
...basePattern.agents[0]!,
model: 'gpt-5.4',
reasoningEffort: 'high',
},
],
};
const project = createProject({
customization: {
instructions: [],
agentProfiles: [],
promptFiles: [
{
id: 'project_customization_prompt_rest_review',
name: 'rest-review',
description: 'Review the REST API surface',
model: 'Claude Sonnet 4.5',
template: 'Review the REST API for security gaps.',
variables: [],
sourcePath: '.github\\prompts\\rest-review.prompt.md',
},
],
lastScannedAt: TIMESTAMP,
},
});
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_rest_review',
name: 'rest-review',
sourcePath: '.github\\prompts\\rest-review.prompt.md',
resolvedPrompt: 'Review the REST API for security gaps.',
});
expect(session.messages.at(-1)?.promptInvocation).toEqual({
id: 'project_customization_prompt_rest_review',
name: 'rest-review',
sourcePath: '.github\\prompts\\rest-review.prompt.md',
description: 'Review the REST API surface',
model: 'Claude Sonnet 4.5',
resolvedPrompt: 'Review the REST API for security gaps.',
});
expect(command?.pattern.agents[0]?.model).toBe('claude-sonnet-4.5');
expect(command?.pattern.agents[0]?.reasoningEffort).toBeUndefined();
expect(command?.promptInvocation).toEqual({
id: 'project_customization_prompt_rest_review',
name: 'rest-review',
sourcePath: '.github\\prompts\\rest-review.prompt.md',
description: 'Review the REST API surface',
model: 'Claude Sonnet 4.5',
resolvedPrompt: 'Review the REST API for security gaps.',
});
});
test('setProjectAgentProfileEnabled persists the updated enabled state', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
+115 -3
View File
@@ -22,7 +22,7 @@ async function createTempDirectory(): Promise<string> {
describe('ProjectCustomizationScanner', () => {
test('discovers recursive instruction, agent, and prompt files with metadata', async () => {
const projectPath = await createTempDirectory();
await mkdir(join(projectPath, '.claude'), { recursive: true });
await mkdir(join(projectPath, '.claude', 'rules'), { recursive: true });
await mkdir(join(projectPath, '.github', 'agents', 'docs'), { recursive: true });
await mkdir(join(projectPath, '.github', 'instructions', 'frontend'), { recursive: true });
await mkdir(join(projectPath, '.github', 'instructions', 'tasks'), { recursive: true });
@@ -48,6 +48,19 @@ Use TypeScript.
'# Claude guidance\nPrefer cohesive designs.\n',
'utf8',
);
await writeFile(
join(projectPath, '.claude', 'rules', 'typescript.md'),
`---
name: Claude TypeScript Rules
description: Claude-format TypeScript rules
paths:
- "src/**/*.ts"
- "src/**/*.tsx"
---
Always use strict TypeScript settings.
`,
'utf8',
);
await writeFile(
join(projectPath, '.github', 'instructions', 'frontend', 'react.instructions.md'),
`---
@@ -55,10 +68,17 @@ name: React Standards
description: React file conventions
applyTo: "**/*.tsx"
---
Apply the [shared standards](./shared-standards.md).
Use hooks and keep components focused.
`,
'utf8',
);
await writeFile(
join(projectPath, '.github', 'instructions', 'frontend', 'shared-standards.md'),
'# Shared Standards\nPrefer explicit props and accessible interactions.\n',
'utf8',
);
await writeFile(
join(projectPath, '.github', 'instructions', 'tasks', 'planning.instructions.md'),
`---
@@ -98,6 +118,8 @@ Focus on repository documentation only.
`---
name: explain-selected-code
agent: agent
model: Claude Sonnet 4.5
argument-hint: Paste code or describe the area you want explained
description: Generate a clear explanation
tools:
- view
@@ -106,9 +128,16 @@ tools:
Explain the following code:
\${input:code:Paste your code here}
Audience: \${input:audience:Who is this for?}
Follow the [shared explanation rubric](./explanation-rubric.md)
`,
'utf8',
);
await writeFile(
join(projectPath, '.github', 'prompts', 'docs', 'explanation-rubric.md'),
'# Explanation rubric\n- Start with a summary.\n- Call out edge cases.\n',
'utf8',
);
const scanned = await new ProjectCustomizationScanner().scanProject(projectPath);
@@ -119,6 +148,15 @@ Audience: \${input:audience:Who is this for?}
content: '# Claude guidance\nPrefer cohesive designs.',
applicationMode: 'always',
},
{
id: expect.any(String),
sourcePath: '.claude\\rules\\typescript.md',
name: 'Claude TypeScript Rules',
description: 'Claude-format TypeScript rules',
applyTo: 'src/**/*.ts,src/**/*.tsx',
content: 'Always use strict TypeScript settings.',
applicationMode: 'file',
},
{
id: expect.any(String),
sourcePath: '.github\\copilot-instructions.md',
@@ -132,7 +170,13 @@ Audience: \${input:audience:Who is this for?}
name: 'React Standards',
description: 'React file conventions',
applyTo: '**/*.tsx',
content: 'Use hooks and keep components focused.',
content:
'Apply the [shared standards](./shared-standards.md).\n\n'
+ 'Use hooks and keep components focused.\n\n'
+ 'Referenced file context:\n\n'
+ 'Source: .github\\instructions\\frontend\\shared-standards.md\n'
+ 'Contents:\n'
+ '# Shared Standards\nPrefer explicit props and accessible interactions.',
applicationMode: 'file',
},
{
@@ -179,9 +223,19 @@ Audience: \${input:audience:Who is this for?}
id: expect.any(String),
name: 'explain-selected-code',
description: 'Generate a clear explanation',
argumentHint: 'Paste code or describe the area you want explained',
agent: 'agent',
model: 'Claude Sonnet 4.5',
tools: ['view', 'glob'],
template: 'Explain the following code:\n${input:code:Paste your code here}\nAudience: ${input:audience:Who is this for?}',
template:
'Explain the following code:\n'
+ '${input:code:Paste your code here}\n'
+ 'Audience: ${input:audience:Who is this for?}\n\n'
+ 'Follow the [shared explanation rubric](./explanation-rubric.md)\n\n'
+ 'Referenced file context:\n\n'
+ 'Source: .github\\prompts\\docs\\explanation-rubric.md\n'
+ 'Contents:\n'
+ '# Explanation rubric\n- Start with a summary.\n- Call out edge cases.',
variables: [
{ name: 'code', placeholder: 'Paste your code here' },
{ name: 'audience', placeholder: 'Who is this for?' },
@@ -192,6 +246,64 @@ Audience: \${input:audience:Who is this for?}
expect(scanned.lastScannedAt).toEqual(expect.any(String));
});
test('discovers customization files from parent repository roots', async () => {
const repoRoot = await createTempDirectory();
const projectPath = join(repoRoot, 'packages', 'frontend');
await mkdir(join(repoRoot, '.git'), { recursive: true });
await mkdir(join(projectPath, 'src'), { recursive: true });
await mkdir(join(repoRoot, '.github', 'prompts'), { recursive: true });
await mkdir(join(repoRoot, '.claude', 'rules'), { recursive: true });
await mkdir(join(repoRoot, 'docs'), { recursive: true });
await writeFile(
join(repoRoot, '.github', 'prompts', 'repo-review.prompt.md'),
`---
name: repo-review
model: GPT-5.4
---
Review the repo using the [checklist](../../docs/review-checklist.md).
`,
'utf8',
);
await writeFile(
join(repoRoot, '.claude', 'rules', 'global.md'),
'Use the monorepo release process.',
'utf8',
);
await writeFile(
join(repoRoot, 'docs', 'review-checklist.md'),
'# Checklist\n- Confirm changelog updates.\n- Check release notes.\n',
'utf8',
);
const scanned = await new ProjectCustomizationScanner().scanProject(projectPath);
expect(scanned.instructions).toEqual([
{
id: expect.any(String),
sourcePath: '..\\..\\.claude\\rules\\global.md',
applyTo: '**',
content: 'Use the monorepo release process.',
applicationMode: 'always',
},
]);
expect(scanned.promptFiles).toEqual([
{
id: expect.any(String),
name: 'repo-review',
model: 'GPT-5.4',
template:
'Review the repo using the [checklist](../../docs/review-checklist.md).\n\n'
+ 'Referenced file context:\n\n'
+ 'Source: ..\\..\\docs\\review-checklist.md\n'
+ 'Contents:\n'
+ '# Checklist\n- Confirm changelog updates.\n- Check release notes.',
variables: [],
sourcePath: '..\\..\\.github\\prompts\\repo-review.prompt.md',
},
]);
});
test('retains the previous parsed agent profile when frontmatter becomes malformed', async () => {
const projectPath = await createTempDirectory();
await mkdir(join(projectPath, '.github', 'agents'), { recursive: true });
@@ -38,6 +38,25 @@ describe('ProjectCustomizationWatcher', () => {
]);
});
test('includes parent repository roots when the project is nested inside a monorepo', async () => {
const repoRoot = await createTempDirectory();
const projectPath = join(repoRoot, 'packages', 'frontend');
await mkdir(join(repoRoot, '.git'), { recursive: true });
await mkdir(join(projectPath, 'src'), { recursive: true });
await mkdir(join(repoRoot, '.github', 'prompts'), { recursive: true });
await mkdir(join(repoRoot, '.claude', 'rules'), { recursive: true });
expect(await collectProjectCustomizationWatchPaths(projectPath)).toEqual([
join(repoRoot, '.claude'),
join(repoRoot, '.claude', 'rules'),
join(repoRoot, '.github'),
join(repoRoot, '.github', 'prompts'),
join(repoRoot, 'packages'),
projectPath,
repoRoot,
].sort((left, right) => left.localeCompare(right)));
});
test('debounces change notifications and closes watches when projects are removed', async () => {
const changeCalls: string[] = [];
const closeByPath = new Map<string, ReturnType<typeof mock>>();
+8
View File
@@ -4,6 +4,7 @@ import type { SidecarModelCapability } from '@shared/contracts/sidecar';
import {
buildAvailableModelCatalog,
findModel,
findModelByReference,
normalizePatternModels,
resolveReasoningEffort,
} from '@shared/domain/models';
@@ -62,6 +63,13 @@ describe('dynamic model catalog', () => {
expect(resolveReasoningEffort(model, 'high')).toBeUndefined();
});
test('resolves model references by id or display name', () => {
const catalog = buildAvailableModelCatalog(availableModels);
expect(findModelByReference('gpt-5.4', catalog)?.id).toBe('gpt-5.4');
expect(findModelByReference('Claude Sonnet 4.5', catalog)?.id).toBe('claude-sonnet-4.5');
});
test('normalizes pattern agents before runtime execution', () => {
const normalized = normalizePatternModels(
createPattern(),
+3
View File
@@ -161,6 +161,7 @@ describe('session library helpers', () => {
name: 'doc-review',
sourcePath: '.github\\prompts\\docs\\doc-review.prompt.md',
resolvedPrompt: 'Review the docs for missing steps.',
model: 'Claude Sonnet 4.5',
tools: ['view'],
},
},
@@ -189,6 +190,7 @@ describe('session library helpers', () => {
name: 'doc-review',
sourcePath: '.github\\prompts\\docs\\doc-review.prompt.md',
resolvedPrompt: 'Review the docs for missing steps.',
model: 'Claude Sonnet 4.5',
tools: ['view'],
});
expect(session.messages[0]?.promptInvocation).not.toBe(sourceSession.messages[0]?.promptInvocation);
@@ -508,6 +510,7 @@ describe('session library helpers', () => {
name: 'alt-plan',
sourcePath: '.github\\prompts\\alt-plan.prompt.md',
resolvedPrompt: 'Try a different approach focused on session state.',
model: 'GPT-5.4',
tools: ['view', 'glob'],
},
attachments: [