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:
David Kaya
2026-03-28 12:28:20 +01:00
co-authored by Copilot
parent 0c2973c599
commit f1fa52f9c3
40 changed files with 2515 additions and 92 deletions
+122 -2
View File
@@ -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,