mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +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:
@@ -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