mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
feat: full Copilot SDK feature parity — custom agents, hooks, image input, skills, steering, session persistence
Backend (sidecar): - Extended ProtocolModels with DTOs for custom agents, hooks, skills, infinite sessions, session lifecycle, and 9 new event types - Added CopilotManagedSessionIds for stable SDK session ID mapping - Added CopilotSessionManager/ICopilotSessionManager for session lifecycle - Added CopilotSessionHooks for hook registration - Added CopilotMessageOptionsMetadata for mid-turn steering - Extended CopilotAgentBundle to wire custom agents, hooks, skills, infinite sessions, and stable session IDs - Extended CopilotTurnExecutionState to project 13 new SDK event types - Widened ITurnWorkflowRunner callback to accept SidecarEventDto - Added list/delete/disconnect session commands to SidecarProtocolHost - Added AryxCopilotAgentMessageOptionsTests (14 new tests, 142 total) Frontend (renderer + main + shared): - Added ChatMessageAttachment type and helpers (attachment.ts) - Extended sidecar contracts with MessageMode, 3 new command types, 9 new event types, and agent/session config DTOs - Extended SessionEventRecord with 6 new event kinds and ~20 fields - Added PatternAgentCopilotConfig to pattern domain - Added attachments support to ChatMessageRecord - Updated sidecar client with session lifecycle methods and turn-scoped event routing via onTurnScopedEvent callback - Updated main process: handleTurnScopedEvent(), deleteSession(), steering bypass for mid-turn messages, attachment passthrough - Added deleteSession IPC handler and preload binding - Added TurnEventLog state tracker with format/apply/prune helpers - ChatPane: always-enabled composer, steering indicator, attachment picker with preview, image thumbnails in message history, context-usage bar, amber steer mode for send button - ActivityPanel: turn events section with sub-agent, hook, skill, and compaction event rendering - Sidebar: delete session action in context menu - App.tsx: wired sessionUsage, turnEventLogs, and deleteSession Documentation: - AGENTS.md: added glob safety rule for node_modules - README.md: added steering, image input, and richer observability - ARCHITECTURE.md: added turn-scoped events, steering, and attachments - Website: added steering and image input feature cards, updated live visibility and session cards Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+122
-2
@@ -14,6 +14,7 @@ import type {
|
||||
TurnDeltaEvent,
|
||||
UserInputRequestedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
|
||||
import {
|
||||
buildAvailableModelCatalog,
|
||||
findModel,
|
||||
@@ -564,10 +565,40 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async sendSessionMessage(sessionId: string, content: string): Promise<void> {
|
||||
async deleteSession(sessionId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const sessionIndex = workspace.sessions.findIndex((s) => s.id === sessionId);
|
||||
if (sessionIndex < 0) {
|
||||
throw new Error(`Session ${sessionId} not found.`);
|
||||
}
|
||||
|
||||
workspace.sessions.splice(sessionIndex, 1);
|
||||
|
||||
if (workspace.selectedSessionId === sessionId) {
|
||||
workspace.selectedSessionId = workspace.sessions[0]?.id;
|
||||
}
|
||||
|
||||
// Clean up corresponding Copilot SDK session data
|
||||
try {
|
||||
await this.sidecar.deleteSession(sessionId);
|
||||
} catch {
|
||||
// Best-effort — don't fail the deletion if SDK cleanup fails
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async sendSessionMessage(
|
||||
sessionId: string,
|
||||
content: string,
|
||||
attachments?: import('@shared/domain/attachment').ChatMessageAttachment[],
|
||||
messageMode?: import('@shared/contracts/sidecar').MessageMode,
|
||||
): Promise<void> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
if (session.status === 'running') {
|
||||
|
||||
// Steering/queueing: allow messages during an active turn when messageMode is set
|
||||
if (session.status === 'running' && !messageMode) {
|
||||
throw new Error('Wait for the current response or approval checkpoint to finish before sending another message.');
|
||||
}
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
@@ -589,6 +620,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
authorName: 'You',
|
||||
content: preparedContent,
|
||||
createdAt: occurredAt,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
});
|
||||
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
|
||||
session.status = 'running';
|
||||
@@ -625,8 +657,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
projectPath: project.path,
|
||||
workspaceKind,
|
||||
mode: session.interactionMode ?? 'interactive',
|
||||
messageMode,
|
||||
pattern: effectivePattern,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
tooling: this.buildRunTurnToolingConfig(workspace, session),
|
||||
},
|
||||
async (event) => {
|
||||
@@ -649,6 +683,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
async (event) => {
|
||||
await this.handleExitPlanModeRequested(workspace, session.id, event);
|
||||
},
|
||||
async (event) => {
|
||||
await this.handleTurnScopedEvent(workspace, session.id, event);
|
||||
},
|
||||
);
|
||||
|
||||
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
|
||||
@@ -1525,6 +1562,89 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
await this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
private handleTurnScopedEvent(
|
||||
_workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: TurnScopedEvent,
|
||||
): void {
|
||||
const occurredAt = nowIso();
|
||||
|
||||
switch (event.type) {
|
||||
case 'subagent-event':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'subagent',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
subagentEventKind: event.eventKind,
|
||||
customAgentName: event.customAgentName,
|
||||
customAgentDisplayName: event.customAgentDisplayName,
|
||||
});
|
||||
return;
|
||||
case 'skill-invoked':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'skill-invoked',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
skillName: event.skillName,
|
||||
skillPath: event.path,
|
||||
pluginName: event.pluginName,
|
||||
});
|
||||
return;
|
||||
case 'hook-lifecycle':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'hook-lifecycle',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
hookInvocationId: event.hookInvocationId,
|
||||
hookType: event.hookType,
|
||||
hookPhase: event.phase,
|
||||
hookSuccess: event.success,
|
||||
});
|
||||
return;
|
||||
case 'session-usage':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'session-usage',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
tokenLimit: event.tokenLimit,
|
||||
currentTokens: event.currentTokens,
|
||||
messagesLength: event.messagesLength,
|
||||
});
|
||||
return;
|
||||
case 'session-compaction':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'session-compaction',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
compactionPhase: event.phase,
|
||||
compactionSuccess: event.success,
|
||||
preCompactionTokens: event.preCompactionTokens,
|
||||
postCompactionTokens: event.postCompactionTokens,
|
||||
tokensRemoved: event.tokensRemoved,
|
||||
});
|
||||
return;
|
||||
case 'pending-messages-modified':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'pending-messages-modified',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
|
||||
return {
|
||||
id: event.approvalId,
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
UpdateSessionApprovalSettingsInput,
|
||||
UpdateSessionToolingInput,
|
||||
UpdateSessionModelConfigInput,
|
||||
DeleteSessionInput,
|
||||
} from '@shared/contracts/ipc';
|
||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||
@@ -106,8 +107,11 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
||||
ipcMain.handle(ipcChannels.setSessionArchived, (_event, input: SetSessionArchivedInput) =>
|
||||
service.setSessionArchived(input.sessionId, input.isArchived),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) =>
|
||||
service.deleteSession(input.sessionId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
||||
service.sendSessionMessage(input.sessionId, input.content),
|
||||
service.sendSessionMessage(input.sessionId, input.content, input.attachments, input.messageMode),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) =>
|
||||
service.cancelSessionTurn(input.sessionId),
|
||||
|
||||
@@ -5,9 +5,23 @@ import type {
|
||||
McpOauthRequiredEvent,
|
||||
TurnDeltaEvent,
|
||||
UserInputRequestedEvent,
|
||||
SubagentEvent,
|
||||
SkillInvokedEvent,
|
||||
HookLifecycleEvent,
|
||||
SessionUsageEvent,
|
||||
SessionCompactionEvent,
|
||||
PendingMessagesModifiedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
export type TurnScopedEvent =
|
||||
| SubagentEvent
|
||||
| SkillInvokedEvent
|
||||
| HookLifecycleEvent
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
| PendingMessagesModifiedEvent;
|
||||
|
||||
export interface RunTurnPendingCommand {
|
||||
kind: 'run-turn';
|
||||
resolve: (messages: ChatMessageRecord[]) => void;
|
||||
@@ -18,6 +32,7 @@ export interface RunTurnPendingCommand {
|
||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
|
||||
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
|
||||
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
|
||||
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>;
|
||||
errored: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
ExitPlanModeRequestedEvent,
|
||||
ValidatePatternCommand,
|
||||
RunTurnCommand,
|
||||
CopilotSessionListFilter,
|
||||
CopilotSessionInfo,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
markRunTurnPendingErrored,
|
||||
shouldHandleRunTurnEvent,
|
||||
type RunTurnPendingCommand,
|
||||
type TurnScopedEvent,
|
||||
} from '@main/sidecar/runTurnPending';
|
||||
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
|
||||
import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime';
|
||||
@@ -59,6 +62,24 @@ type PendingCommand =
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'list-sessions';
|
||||
resolve: (sessions: CopilotSessionInfo[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'delete-session';
|
||||
resolve: (sessions: CopilotSessionInfo[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'disconnect-session';
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
} & RunTurnPendingCommand);
|
||||
@@ -106,8 +127,9 @@ export class SidecarClient {
|
||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
||||
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
||||
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
|
||||
): Promise<ChatMessageRecord[]> {
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode);
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onTurnScopedEvent);
|
||||
}
|
||||
|
||||
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
|
||||
@@ -138,6 +160,31 @@ export class SidecarClient {
|
||||
} satisfies CancelTurnCommand);
|
||||
}
|
||||
|
||||
async listSessions(filter?: CopilotSessionListFilter): Promise<CopilotSessionInfo[]> {
|
||||
return this.dispatch<CopilotSessionInfo[]>({
|
||||
type: 'list-sessions',
|
||||
requestId: `list-sessions-${Date.now()}`,
|
||||
filter,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSession(sessionId?: string, copilotSessionId?: string): Promise<CopilotSessionInfo[]> {
|
||||
return this.dispatch<CopilotSessionInfo[]>({
|
||||
type: 'delete-session',
|
||||
requestId: `delete-session-${Date.now()}`,
|
||||
sessionId,
|
||||
copilotSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async disconnectSession(sessionId: string): Promise<void> {
|
||||
return this.dispatch<void>({
|
||||
type: 'disconnect-session',
|
||||
requestId: `disconnect-session-${Date.now()}`,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
const state = this.processState;
|
||||
if (!state) {
|
||||
@@ -225,6 +272,7 @@ export class SidecarClient {
|
||||
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
||||
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
||||
onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>,
|
||||
): Promise<TResult> {
|
||||
const state = await this.ensureProcess();
|
||||
|
||||
@@ -241,6 +289,7 @@ export class SidecarClient {
|
||||
onUserInput: onUserInput ?? (() => undefined),
|
||||
onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined),
|
||||
onExitPlanMode: onExitPlanMode ?? (() => undefined),
|
||||
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
|
||||
errored: false,
|
||||
});
|
||||
} else if (command.type === 'validate-pattern') {
|
||||
@@ -271,6 +320,27 @@ export class SidecarClient {
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'list-sessions') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'list-sessions',
|
||||
resolve: resolve as (sessions: CopilotSessionInfo[]) => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'delete-session') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'delete-session',
|
||||
resolve: resolve as (sessions: CopilotSessionInfo[]) => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'disconnect-session') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'disconnect-session',
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
@@ -348,6 +418,34 @@ export class SidecarClient {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event));
|
||||
}
|
||||
return;
|
||||
case 'subagent-event':
|
||||
case 'skill-invoked':
|
||||
case 'hook-lifecycle':
|
||||
case 'session-usage':
|
||||
case 'session-compaction':
|
||||
case 'pending-messages-modified':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
|
||||
}
|
||||
return;
|
||||
case 'sessions-listed':
|
||||
if (pending.kind === 'list-sessions') {
|
||||
pending.resolve(event.sessions);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'sessions-deleted':
|
||||
if (pending.kind === 'delete-session') {
|
||||
pending.resolve(event.sessions);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'session-disconnected':
|
||||
if (pending.kind === 'disconnect-session') {
|
||||
pending.resolve();
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'turn-complete':
|
||||
if (pending.kind === 'run-turn') {
|
||||
if (shouldHandleRunTurnEvent(pending)) {
|
||||
|
||||
Reference in New Issue
Block a user