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,
+5 -1
View File
@@ -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),
+15
View File
@@ -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;
}
+99 -1
View File
@@ -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)) {