mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-03 18:38:35 +02:00
feat: add ask_user interactive user input to frontend
Wire the sidecar user-input-requested protocol event through the main process, IPC layer, and renderer UI so agents can ask the user interactive questions with choices and freeform input. - Add UserInputRequestedEvent and ResolveUserInputCommand to sidecar protocol types - Add resolveUserInput method to SidecarClient and onUserInput callback to runTurn - Create PendingUserInputRecord domain type and add pendingUserInput to SessionRecord - Add handleUserInputRequested and resolveSessionUserInput to AryxAppService with handle management - Register sessions:resolve-user-input IPC channel with preload bridge - Create UserInputBanner component with choice buttons and freeform input - Integrate UserInputBanner into ChatPane with header indicator Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
||||
RunTurnToolingConfig,
|
||||
SidecarCapabilities,
|
||||
TurnDeltaEvent,
|
||||
UserInputRequestedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import {
|
||||
buildAvailableModelCatalog,
|
||||
@@ -120,6 +121,12 @@ type PendingApprovalHandle = {
|
||||
resolve: (decision: ApprovalDecision) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type PendingUserInputHandle = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
||||
|
||||
function isBuiltinPattern(patternId: string): boolean {
|
||||
@@ -147,6 +154,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly gitService = new GitService();
|
||||
private readonly configScanner = new ConfigScannerRegistry();
|
||||
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
|
||||
private readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
|
||||
private workspace?: WorkspaceState;
|
||||
private sidecarCapabilities?: SidecarCapabilities;
|
||||
private sidecarCapabilitiesPromise?: Promise<SidecarCapabilities>;
|
||||
@@ -619,6 +627,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision) =>
|
||||
this.sidecar.resolveApproval(event.approvalId, decision));
|
||||
},
|
||||
async (event) => {
|
||||
await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) =>
|
||||
this.sidecar.resolveUserInput(event.userInputId, answer, wasFreeform));
|
||||
},
|
||||
);
|
||||
|
||||
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
|
||||
@@ -742,6 +754,59 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return result;
|
||||
}
|
||||
|
||||
async resolveSessionUserInput(
|
||||
sessionId: string,
|
||||
userInputId: string,
|
||||
answer: string,
|
||||
wasFreeform: boolean,
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const pending = session.pendingUserInput;
|
||||
if (!pending || pending.id !== userInputId) {
|
||||
throw new Error(`User input "${userInputId}" is not pending for session "${sessionId}".`);
|
||||
}
|
||||
|
||||
const handle = this.pendingUserInputHandles.get(userInputId);
|
||||
if (!handle || handle.sessionId !== sessionId) {
|
||||
throw new Error(`User input "${userInputId}" is no longer active. Restart the run and try again.`);
|
||||
}
|
||||
|
||||
const answeredAt = nowIso();
|
||||
session.pendingUserInput = {
|
||||
...pending,
|
||||
status: 'answered',
|
||||
answer,
|
||||
answeredAt,
|
||||
};
|
||||
session.updatedAt = answeredAt;
|
||||
|
||||
const result = await this.persistAndBroadcast(workspace);
|
||||
this.pendingUserInputHandles.delete(userInputId);
|
||||
|
||||
try {
|
||||
await Promise.resolve(handle.resolve(answer, wasFreeform));
|
||||
session.pendingUserInput = undefined;
|
||||
await this.persistAndBroadcast(workspace);
|
||||
} catch (error) {
|
||||
session.status = 'error';
|
||||
session.lastError = error instanceof Error ? error.message : String(error);
|
||||
session.updatedAt = nowIso();
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'error',
|
||||
occurredAt: session.updatedAt,
|
||||
error: session.lastError,
|
||||
});
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateSessionModelConfig(
|
||||
sessionId: string,
|
||||
model: string,
|
||||
@@ -1114,6 +1179,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const completedAt = nowIso();
|
||||
session.status = 'idle';
|
||||
session.lastError = undefined;
|
||||
session.pendingUserInput = undefined;
|
||||
session.updatedAt = completedAt;
|
||||
const completedRun = this.updateSessionRun(session, requestId, (run) =>
|
||||
completeSessionRunRecord(run, completedAt));
|
||||
@@ -1144,6 +1210,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const cancelledAt = nowIso();
|
||||
session.status = 'idle';
|
||||
session.lastError = undefined;
|
||||
session.pendingUserInput = undefined;
|
||||
session.updatedAt = cancelledAt;
|
||||
const cancelledRun = this.updateSessionRun(session, requestId, (run) =>
|
||||
cancelSessionRunRecord(run, cancelledAt));
|
||||
@@ -1187,6 +1254,37 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUserInputRequested(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
_requestId: string,
|
||||
event: UserInputRequestedEvent,
|
||||
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const requestedAt = nowIso();
|
||||
|
||||
session.pendingUserInput = {
|
||||
id: event.userInputId,
|
||||
status: 'pending',
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
question: event.question,
|
||||
choices: event.choices,
|
||||
allowFreeform: event.allowFreeform ?? true,
|
||||
requestedAt,
|
||||
};
|
||||
session.updatedAt = requestedAt;
|
||||
|
||||
this.pendingUserInputHandles.set(event.userInputId, {
|
||||
sessionId,
|
||||
requestId: _requestId,
|
||||
resolve,
|
||||
});
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
|
||||
return {
|
||||
id: event.approvalId,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
RenameSessionInput,
|
||||
RescanProjectConfigsInput,
|
||||
ResolveSessionApprovalInput,
|
||||
ResolveSessionUserInputInput,
|
||||
SaveLspProfileInput,
|
||||
SaveMcpServerInput,
|
||||
SavePatternInput,
|
||||
@@ -110,6 +111,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
||||
ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) =>
|
||||
service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.resolveSessionUserInput, (_event, input: ResolveSessionUserInputInput) =>
|
||||
service.resolveSessionUserInput(input.sessionId, input.userInputId, input.answer, input.wasFreeform),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.updateSessionModelConfig,
|
||||
(_event, input: UpdateSessionModelConfigInput) =>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { AgentActivityEvent, ApprovalRequestedEvent, TurnDeltaEvent } from '@shared/contracts/sidecar';
|
||||
import type {
|
||||
AgentActivityEvent,
|
||||
ApprovalRequestedEvent,
|
||||
TurnDeltaEvent,
|
||||
UserInputRequestedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
export interface RunTurnPendingCommand {
|
||||
@@ -8,6 +13,7 @@ export interface RunTurnPendingCommand {
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>;
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>;
|
||||
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>;
|
||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
|
||||
errored: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SidecarCapabilities,
|
||||
SidecarEvent,
|
||||
TurnDeltaEvent,
|
||||
UserInputRequestedEvent,
|
||||
ValidatePatternCommand,
|
||||
RunTurnCommand,
|
||||
} from '@shared/contracts/sidecar';
|
||||
@@ -44,6 +45,12 @@ type PendingCommand =
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'resolve-user-input';
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'cancel-turn';
|
||||
@@ -94,8 +101,19 @@ export class SidecarClient {
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||
): Promise<ChatMessageRecord[]> {
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval);
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput);
|
||||
}
|
||||
|
||||
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
|
||||
return this.dispatch<void>({
|
||||
type: 'resolve-user-input',
|
||||
requestId: `user-input-${Date.now()}`,
|
||||
userInputId,
|
||||
answer,
|
||||
wasFreeform,
|
||||
});
|
||||
}
|
||||
|
||||
async resolveApproval(approvalId: string, decision: ApprovalDecision): Promise<void> {
|
||||
@@ -199,6 +217,7 @@ export class SidecarClient {
|
||||
onDelta?: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||
): Promise<TResult> {
|
||||
const state = await this.ensureProcess();
|
||||
|
||||
@@ -212,6 +231,7 @@ export class SidecarClient {
|
||||
onDelta: onDelta ?? (() => undefined),
|
||||
onActivity: onActivity ?? (() => undefined),
|
||||
onApproval: onApproval ?? (() => undefined),
|
||||
onUserInput: onUserInput ?? (() => undefined),
|
||||
errored: false,
|
||||
});
|
||||
} else if (command.type === 'validate-pattern') {
|
||||
@@ -228,6 +248,13 @@ export class SidecarClient {
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'resolve-user-input') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'resolve-user-input',
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'cancel-turn') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
@@ -297,6 +324,11 @@ export class SidecarClient {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onApproval(event));
|
||||
}
|
||||
return;
|
||||
case 'user-input-requested':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onUserInput(event));
|
||||
}
|
||||
return;
|
||||
case 'turn-complete':
|
||||
if (pending.kind === 'run-turn') {
|
||||
if (shouldHandleRunTurnEvent(pending)) {
|
||||
@@ -318,7 +350,7 @@ export class SidecarClient {
|
||||
this.pending.delete(event.requestId);
|
||||
return;
|
||||
case 'command-complete':
|
||||
if (pending.kind === 'resolve-approval' || pending.kind === 'cancel-turn') {
|
||||
if (pending.kind === 'resolve-approval' || pending.kind === 'resolve-user-input' || pending.kind === 'cancel-turn') {
|
||||
pending.resolve();
|
||||
this.pending.delete(event.requestId);
|
||||
} else if (pending.kind !== 'run-turn' || pending.errored) {
|
||||
|
||||
Reference in New Issue
Block a user