mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-04 02:48:44 +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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user