mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-04 02:48:44 +02:00
feat: add turn cancellation UI and main process wiring
Adds cancel-turn IPC channel, SidecarClient.cancelTurn(), TurnCancelledError, EryxAppService.cancelSessionTurn(), finalizeCancelledTurn(), stop button in ChatPane, and cancelled run status throughout the run timeline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -65,6 +65,7 @@ import {
|
||||
import { prepareChatMessageContent } from '@shared/utils/chatMessage';
|
||||
import {
|
||||
appendRunActivityEvent,
|
||||
cancelSessionRunRecord,
|
||||
completeSessionRunRecord,
|
||||
createSessionRunRecord,
|
||||
failSessionRunRecord,
|
||||
@@ -99,6 +100,7 @@ import {
|
||||
SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE,
|
||||
SidecarClient,
|
||||
} from '@main/sidecar/sidecarProcess';
|
||||
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
|
||||
import { GitService } from '@main/git/gitService';
|
||||
import {
|
||||
buildRunTurnToolingConfig as buildSessionToolingConfig,
|
||||
@@ -621,6 +623,12 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
|
||||
await this.persistAndBroadcast(workspace);
|
||||
} catch (error) {
|
||||
if (error instanceof TurnCancelledError) {
|
||||
this.finalizeCancelledTurn(workspace, session, requestId);
|
||||
await this.persistAndBroadcast(workspace);
|
||||
return;
|
||||
}
|
||||
|
||||
const failedAt = nowIso();
|
||||
session.status = 'error';
|
||||
session.lastError = error instanceof Error ? error.message : String(error);
|
||||
@@ -643,6 +651,21 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSessionTurn(sessionId: string): Promise<void> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
if (session.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
const runningRun = session.runs.find((run) => run.status === 'running');
|
||||
if (!runningRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sidecar.cancelTurn(runningRun.requestId);
|
||||
}
|
||||
|
||||
async resolveSessionApproval(
|
||||
sessionId: string,
|
||||
approvalId: string,
|
||||
@@ -1101,6 +1124,36 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
private finalizeCancelledTurn(
|
||||
workspace: WorkspaceState,
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
): void {
|
||||
for (const message of session.messages) {
|
||||
if (message.pending) {
|
||||
message.pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.rejectPendingApprovals(session, nowIso(), 'The turn was cancelled.');
|
||||
|
||||
const cancelledAt = nowIso();
|
||||
session.status = 'idle';
|
||||
session.lastError = undefined;
|
||||
session.updatedAt = cancelledAt;
|
||||
const cancelledRun = this.updateSessionRun(session, requestId, (run) =>
|
||||
cancelSessionRunRecord(run, cancelledAt));
|
||||
this.emitSessionEvent({
|
||||
sessionId: session.id,
|
||||
kind: 'status',
|
||||
occurredAt: cancelledAt,
|
||||
status: 'idle',
|
||||
});
|
||||
if (cancelledRun) {
|
||||
this.emitRunUpdated(session.id, cancelledAt, cancelledRun);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApprovalRequested(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BrowserWindow, ipcMain } from 'electron';
|
||||
|
||||
import { ipcChannels } from '@shared/contracts/channels';
|
||||
import type {
|
||||
CancelSessionTurnInput,
|
||||
CreateSessionInput,
|
||||
ResolveProjectDiscoveredToolingInput,
|
||||
ResolveWorkspaceDiscoveredToolingInput,
|
||||
@@ -100,6 +101,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi
|
||||
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
||||
service.sendSessionMessage(input.sessionId, input.content),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) =>
|
||||
service.cancelSessionTurn(input.sessionId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) =>
|
||||
service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import type {
|
||||
AgentActivityEvent,
|
||||
ApprovalRequestedEvent,
|
||||
CancelTurnCommand,
|
||||
SidecarCommand,
|
||||
SidecarCapabilities,
|
||||
SidecarEvent,
|
||||
@@ -19,29 +20,54 @@ import {
|
||||
shouldHandleRunTurnEvent,
|
||||
type RunTurnPendingCommand,
|
||||
} from '@main/sidecar/runTurnPending';
|
||||
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
|
||||
import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime';
|
||||
|
||||
type PendingCommand =
|
||||
| {
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'capabilities';
|
||||
resolve: (capabilities: SidecarCapabilities) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
| {
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'validate-pattern';
|
||||
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
| {
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'resolve-approval';
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
| RunTurnPendingCommand;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'cancel-turn';
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
} & RunTurnPendingCommand);
|
||||
|
||||
type ManagedSidecarProcess = {
|
||||
id: number;
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
stdoutBuffer: string;
|
||||
exitExpected: boolean;
|
||||
terminated: boolean;
|
||||
closed: Promise<void>;
|
||||
resolveClosed: () => void;
|
||||
};
|
||||
|
||||
export const SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE =
|
||||
'The .NET sidecar was stopped before the command completed.';
|
||||
|
||||
export class SidecarClient {
|
||||
private process?: ChildProcessWithoutNullStreams;
|
||||
private stdoutBuffer = '';
|
||||
private processState?: ManagedSidecarProcess;
|
||||
private nextProcessId = 0;
|
||||
private readonly pending = new Map<string, PendingCommand>();
|
||||
|
||||
async describeCapabilities(): Promise<SidecarCapabilities> {
|
||||
@@ -79,18 +105,39 @@ export class SidecarClient {
|
||||
});
|
||||
}
|
||||
|
||||
async cancelTurn(targetRequestId: string): Promise<void> {
|
||||
return this.dispatch<void>({
|
||||
type: 'cancel-turn',
|
||||
requestId: `cancel-${Date.now()}`,
|
||||
targetRequestId,
|
||||
} satisfies CancelTurnCommand);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (!this.process) {
|
||||
const state = this.processState;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.process.kill();
|
||||
this.process = undefined;
|
||||
state.exitExpected = true;
|
||||
if (!state.child.killed && state.child.exitCode === null) {
|
||||
state.child.kill();
|
||||
}
|
||||
await state.closed;
|
||||
}
|
||||
|
||||
private async ensureProcess(): Promise<ChildProcessWithoutNullStreams> {
|
||||
if (this.process && !this.process.killed) {
|
||||
return this.process;
|
||||
private async ensureProcess(): Promise<ManagedSidecarProcess> {
|
||||
if (
|
||||
this.processState &&
|
||||
!this.processState.exitExpected &&
|
||||
!this.processState.terminated &&
|
||||
this.processState.child.exitCode === null
|
||||
) {
|
||||
return this.processState;
|
||||
}
|
||||
|
||||
if (this.processState) {
|
||||
await this.processState.closed;
|
||||
}
|
||||
|
||||
const sidecar = resolveSidecarProcess({
|
||||
@@ -105,12 +152,29 @@ export class SidecarClient {
|
||||
stdio: 'pipe',
|
||||
windowsHide: true,
|
||||
});
|
||||
this.process = childProcess;
|
||||
let resolveClosed!: () => void;
|
||||
const state: ManagedSidecarProcess = {
|
||||
id: this.nextProcessId + 1,
|
||||
child: childProcess,
|
||||
stdoutBuffer: '',
|
||||
exitExpected: false,
|
||||
terminated: false,
|
||||
closed: new Promise<void>((resolve) => {
|
||||
resolveClosed = resolve;
|
||||
}),
|
||||
resolveClosed,
|
||||
};
|
||||
this.nextProcessId = state.id;
|
||||
this.processState = state;
|
||||
|
||||
childProcess.stdout.setEncoding('utf8');
|
||||
childProcess.stdout.on('data', (chunk: string) => {
|
||||
this.stdoutBuffer += chunk;
|
||||
this.flushStdoutBuffer();
|
||||
if (state.terminated) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.stdoutBuffer += chunk;
|
||||
this.flushStdoutBuffer(state);
|
||||
});
|
||||
|
||||
childProcess.stderr.setEncoding('utf8');
|
||||
@@ -118,17 +182,14 @@ export class SidecarClient {
|
||||
console.error('[aryx sidecar]', chunk.trim());
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code) => {
|
||||
const error = new Error(`The .NET sidecar exited unexpectedly with code ${code ?? 'unknown'}.`);
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.process = undefined;
|
||||
this.stdoutBuffer = '';
|
||||
childProcess.on('close', (code) => {
|
||||
const error = state.exitExpected
|
||||
? new Error(SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE)
|
||||
: new Error(`The .NET sidecar exited unexpectedly with code ${code ?? 'unknown'}.`);
|
||||
this.handleProcessClosed(state, error);
|
||||
});
|
||||
|
||||
return childProcess;
|
||||
return state;
|
||||
}
|
||||
|
||||
private async dispatch<TResult>(
|
||||
@@ -137,11 +198,12 @@ export class SidecarClient {
|
||||
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||
): Promise<TResult> {
|
||||
const process = await this.ensureProcess();
|
||||
const state = await this.ensureProcess();
|
||||
|
||||
return new Promise<TResult>((resolve, reject) => {
|
||||
if (command.type === 'run-turn') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'run-turn',
|
||||
resolve: resolve as (messages: ChatMessageRecord[]) => void,
|
||||
reject,
|
||||
@@ -152,46 +214,56 @@ export class SidecarClient {
|
||||
});
|
||||
} else if (command.type === 'validate-pattern') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'validate-pattern',
|
||||
resolve: resolve as (issues: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'resolve-approval') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'resolve-approval',
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'cancel-turn') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'cancel-turn',
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'capabilities',
|
||||
resolve: resolve as (capabilities: SidecarCapabilities) => void,
|
||||
reject,
|
||||
});
|
||||
}
|
||||
|
||||
process.stdin.write(`${JSON.stringify(command)}\n`);
|
||||
state.child.stdin.write(`${JSON.stringify(command)}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
private flushStdoutBuffer(): void {
|
||||
let newlineIndex = this.stdoutBuffer.indexOf('\n');
|
||||
private flushStdoutBuffer(state: ManagedSidecarProcess): void {
|
||||
let newlineIndex = state.stdoutBuffer.indexOf('\n');
|
||||
|
||||
while (newlineIndex >= 0) {
|
||||
const rawLine = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
||||
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
||||
const rawLine = state.stdoutBuffer.slice(0, newlineIndex).trim();
|
||||
state.stdoutBuffer = state.stdoutBuffer.slice(newlineIndex + 1);
|
||||
|
||||
if (rawLine) {
|
||||
this.handleEvent(JSON.parse(rawLine) as SidecarEvent);
|
||||
this.handleEvent(state.id, JSON.parse(rawLine) as SidecarEvent);
|
||||
}
|
||||
|
||||
newlineIndex = this.stdoutBuffer.indexOf('\n');
|
||||
newlineIndex = state.stdoutBuffer.indexOf('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: SidecarEvent): void {
|
||||
private handleEvent(processId: number, event: SidecarEvent): void {
|
||||
const pending = this.pending.get(event.requestId);
|
||||
if (!pending) {
|
||||
if (!pending || pending.processId !== processId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -226,7 +298,11 @@ export class SidecarClient {
|
||||
case 'turn-complete':
|
||||
if (pending.kind === 'run-turn') {
|
||||
if (shouldHandleRunTurnEvent(pending)) {
|
||||
pending.resolve(event.messages);
|
||||
if (event.cancelled) {
|
||||
markRunTurnPendingErrored(pending, new TurnCancelledError());
|
||||
} else {
|
||||
pending.resolve(event.messages);
|
||||
}
|
||||
}
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
@@ -240,7 +316,7 @@ export class SidecarClient {
|
||||
this.pending.delete(event.requestId);
|
||||
return;
|
||||
case 'command-complete':
|
||||
if (pending.kind === 'resolve-approval') {
|
||||
if (pending.kind === 'resolve-approval' || pending.kind === 'cancel-turn') {
|
||||
pending.resolve();
|
||||
this.pending.delete(event.requestId);
|
||||
} else if (pending.kind !== 'run-turn' || pending.errored) {
|
||||
@@ -262,4 +338,27 @@ export class SidecarClient {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleProcessClosed(state: ManagedSidecarProcess, error: Error): void {
|
||||
if (state.terminated) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.terminated = true;
|
||||
if (this.processState === state) {
|
||||
this.processState = undefined;
|
||||
}
|
||||
|
||||
state.stdoutBuffer = '';
|
||||
state.resolveClosed();
|
||||
|
||||
for (const [requestId, pending] of this.pending.entries()) {
|
||||
if (pending.processId !== state.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pending.reject(error);
|
||||
this.pending.delete(requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export class TurnCancelledError extends Error {
|
||||
constructor() {
|
||||
super('The turn was cancelled.');
|
||||
this.name = 'TurnCancelledError';
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ const api: ElectronApi = {
|
||||
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
|
||||
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
|
||||
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
|
||||
cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input),
|
||||
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input),
|
||||
updateScratchpadSessionConfig: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.updateScratchpadSessionConfig, input),
|
||||
|
||||
@@ -246,6 +246,7 @@ export default function App() {
|
||||
content = (
|
||||
<ChatPane
|
||||
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
|
||||
onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
|
||||
onResolveApproval={(approvalId, decision) =>
|
||||
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision })
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, ShieldAlert, User } from 'lucide-react';
|
||||
import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, ShieldAlert, Square, User } from 'lucide-react';
|
||||
|
||||
import { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
|
||||
@@ -34,6 +34,7 @@ interface ChatPaneProps {
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||
onSend: (content: string) => Promise<void>;
|
||||
onCancelTurn?: () => void;
|
||||
onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>;
|
||||
onUpdateScratchpadConfig?: (config: {
|
||||
model: string;
|
||||
@@ -51,6 +52,7 @@ export function ChatPane({
|
||||
toolingSettings,
|
||||
runtimeTools,
|
||||
onSend,
|
||||
onCancelTurn,
|
||||
onResolveApproval,
|
||||
onUpdateScratchpadConfig,
|
||||
onUpdateSessionTooling,
|
||||
@@ -432,16 +434,25 @@ export function ChatPane({
|
||||
>
|
||||
<button
|
||||
className={`absolute bottom-2 right-2 flex size-8 items-center justify-center rounded-lg transition ${
|
||||
canSubmitInput
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||
: 'bg-zinc-800 text-zinc-600'
|
||||
isSessionBusy
|
||||
? 'bg-red-600/80 text-white hover:bg-red-500'
|
||||
: canSubmitInput
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||
: 'bg-zinc-800 text-zinc-600'
|
||||
}`}
|
||||
disabled={!canSubmitInput}
|
||||
onClick={() => composerRef.current?.submit()}
|
||||
disabled={!canSubmitInput && !isSessionBusy}
|
||||
onClick={() => {
|
||||
if (isSessionBusy) {
|
||||
onCancelTurn?.();
|
||||
} else {
|
||||
composerRef.current?.submit();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
aria-label={isSessionBusy ? 'Stop generating' : 'Send message'}
|
||||
>
|
||||
{isSessionBusy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<Square className="size-3.5" fill="currentColor" />
|
||||
) : (
|
||||
<ArrowUp className="size-4" />
|
||||
)}
|
||||
|
||||
@@ -43,6 +43,7 @@ const modeAccent: Record<OrchestrationMode, { dot: string; ring: string; text: s
|
||||
const runStatusStyles: Record<SessionRunRecord['status'], { icon: ReactNode; className: string }> = {
|
||||
running: { icon: <CircleDot className="size-3" />, className: 'text-blue-400' },
|
||||
completed: { icon: <CheckCircle2 className="size-3" />, className: 'text-emerald-400' },
|
||||
cancelled: { icon: <XCircle className="size-3" />, className: 'text-zinc-400' },
|
||||
error: { icon: <XCircle className="size-3" />, className: 'text-red-400' },
|
||||
};
|
||||
|
||||
@@ -65,6 +66,8 @@ function EventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; sta
|
||||
return <MessageSquare className={`${base} ${status === 'running' ? 'text-blue-400 animate-pulse' : status === 'error' ? 'text-red-400' : 'text-emerald-400'}`} />;
|
||||
case 'run-completed':
|
||||
return <CheckCircle2 className={`${base} text-emerald-400`} />;
|
||||
case 'run-cancelled':
|
||||
return <XCircle className={`${base} text-zinc-400`} />;
|
||||
case 'run-failed':
|
||||
return <AlertTriangle className={`${base} text-red-400`} />;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ export function formatRunStatusLabel(status: SessionRunStatus): string {
|
||||
return 'Running';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'cancelled':
|
||||
return 'Cancelled';
|
||||
case 'error':
|
||||
return 'Failed';
|
||||
}
|
||||
@@ -91,6 +93,8 @@ export function formatEventLabel(event: RunTimelineEventRecord): string {
|
||||
return event.agentName ?? 'Response';
|
||||
case 'run-completed':
|
||||
return 'Completed';
|
||||
case 'run-cancelled':
|
||||
return 'Cancelled';
|
||||
case 'run-failed':
|
||||
return 'Failed';
|
||||
}
|
||||
@@ -144,11 +148,12 @@ const eventKindOrder: Record<RunTimelineEventKind, number> = {
|
||||
'approval': 4,
|
||||
'message': 5,
|
||||
'run-completed': 6,
|
||||
'run-cancelled': 6,
|
||||
'run-failed': 6,
|
||||
};
|
||||
|
||||
export function isTerminalEvent(kind: RunTimelineEventKind): boolean {
|
||||
return kind === 'run-completed' || kind === 'run-failed' || kind === 'run-started';
|
||||
return kind === 'run-completed' || kind === 'run-cancelled' || kind === 'run-failed' || kind === 'run-started';
|
||||
}
|
||||
|
||||
export function eventSortKey(event: RunTimelineEventRecord): number {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const ipcChannels = {
|
||||
setSessionPinned: 'sessions:set-pinned',
|
||||
setSessionArchived: 'sessions:set-archived',
|
||||
sendSessionMessage: 'sessions:send-message',
|
||||
cancelSessionTurn: 'sessions:cancel-turn',
|
||||
resolveSessionApproval: 'sessions:resolve-approval',
|
||||
querySessions: 'sessions:query',
|
||||
updateScratchpadSessionConfig: 'sessions:update-scratchpad-config',
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface SendSessionMessageInput {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface CancelSessionTurnInput {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface ResolveSessionApprovalInput {
|
||||
sessionId: string;
|
||||
approvalId: string;
|
||||
@@ -120,6 +124,7 @@ export interface ElectronApi {
|
||||
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
|
||||
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
|
||||
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
|
||||
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
|
||||
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
|
||||
updateScratchpadSessionConfig(input: UpdateScratchpadSessionConfigInput): Promise<WorkspaceState>;
|
||||
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
|
||||
|
||||
@@ -79,6 +79,12 @@ export interface RunTurnCommand {
|
||||
tooling?: RunTurnToolingConfig;
|
||||
}
|
||||
|
||||
export interface CancelTurnCommand {
|
||||
type: 'cancel-turn';
|
||||
requestId: string;
|
||||
targetRequestId: string;
|
||||
}
|
||||
|
||||
export interface ResolveApprovalCommand {
|
||||
type: 'resolve-approval';
|
||||
requestId: string;
|
||||
@@ -90,6 +96,7 @@ export type SidecarCommand =
|
||||
| DescribeCapabilitiesCommand
|
||||
| ValidatePatternCommand
|
||||
| RunTurnCommand
|
||||
| CancelTurnCommand
|
||||
| ResolveApprovalCommand;
|
||||
|
||||
export interface RunTurnLocalMcpServerConfig {
|
||||
@@ -157,6 +164,7 @@ export interface TurnCompleteEvent {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
messages: ChatMessageRecord[];
|
||||
cancelled?: boolean;
|
||||
}
|
||||
|
||||
export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import { createId } from '@shared/utils/ids';
|
||||
|
||||
export type SessionRunStatus = 'running' | 'completed' | 'error';
|
||||
export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error';
|
||||
export type SessionRunWorkspaceKind = 'project' | 'scratchpad';
|
||||
export type RunTimelineEventKind =
|
||||
| 'run-started'
|
||||
@@ -17,6 +17,7 @@ export type RunTimelineEventKind =
|
||||
| 'approval'
|
||||
| 'message'
|
||||
| 'run-completed'
|
||||
| 'run-cancelled'
|
||||
| 'run-failed';
|
||||
export type RunTimelineEventStatus = 'running' | 'completed' | 'error';
|
||||
|
||||
@@ -318,7 +319,7 @@ export function normalizeSessionRunRecords(
|
||||
triggerMessageId,
|
||||
startedAt,
|
||||
completedAt: normalizeOptionalString(run.completedAt),
|
||||
status: run.status === 'error' ? 'error' : run.status === 'running' ? 'running' : 'completed',
|
||||
status: run.status === 'error' ? 'error' : run.status === 'running' ? 'running' : run.status === 'cancelled' ? 'cancelled' : 'completed',
|
||||
agents: run.agents.flatMap((agent) => {
|
||||
const normalized = normalizeRunTimelineAgent(agent);
|
||||
return normalized ? [normalized] : [];
|
||||
@@ -516,6 +517,24 @@ export function completeSessionRunRecord(
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelSessionRunRecord(
|
||||
run: SessionRunRecord,
|
||||
cancelledAt: string,
|
||||
): SessionRunRecord {
|
||||
const settledRun = settleOpenMessageEvents(run, 'completed', cancelledAt);
|
||||
const cancelledRun: SessionRunRecord = {
|
||||
...settledRun,
|
||||
status: 'cancelled',
|
||||
completedAt: cancelledAt,
|
||||
};
|
||||
|
||||
return appendRunTimelineEvent(cancelledRun, {
|
||||
kind: 'run-cancelled',
|
||||
occurredAt: cancelledAt,
|
||||
status: 'completed',
|
||||
});
|
||||
}
|
||||
|
||||
export function failSessionRunRecord(
|
||||
run: SessionRunRecord,
|
||||
failedAt: string,
|
||||
|
||||
Reference in New Issue
Block a user