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:
David Kaya
2026-04-02 11:41:18 +02:00
co-authored by Copilot
parent aa7830f01a
commit 5d69d9d855
21 changed files with 900 additions and 47 deletions
@@ -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');
+4
View File
@@ -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();
});
});
+58 -33
View File
@@ -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',