From e012c8f52e7173a1fe6bfaa96ac74832a291c6d1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 23:21:37 +0700 Subject: [PATCH] feat: return agent snapshots from cancel and clear-attention RPCs Convert cancel_agent_request and clear_agent_attention from fire-and-forget to request/response RPCs that return the authoritative agent snapshot. This enables client-side self-healing when agent_update messages are missed (e.g. agent appears stuck running when it isn't). --- packages/server/src/client/daemon-client.ts | 46 +++++++++++++++++++-- packages/server/src/server/session.ts | 41 ++++++++++++++++-- packages/server/src/shared/messages.ts | 26 ++++++++++++ 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 2d7ba7db4..16edd9038 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -1187,8 +1187,28 @@ export class DaemonClient { } } - clearAgentAttention(agentId: string | string[]): void { - this.sendSessionMessage({ type: "clear_agent_attention", agentId }); + async clearAgentAttention(agentId: string | string[]): Promise { + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "clear_agent_attention", + agentId, + requestId, + }); + await this.sendRequest({ + requestId, + message, + timeout: 15000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "clear_agent_attention_response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); } sendHeartbeat(params: { @@ -1692,7 +1712,27 @@ export class DaemonClient { } async cancelAgent(agentId: string): Promise { - this.sendSessionMessage({ type: "cancel_agent_request", agentId }); + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "cancel_agent_request", + agentId, + requestId, + }); + await this.sendRequest({ + requestId, + message, + timeout: 15000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "cancel_agent_response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); } async setAgentMode(agentId: string, modeId: string): Promise { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1dcc805e9..e141ceddf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1752,7 +1752,7 @@ export class Session { break; case "cancel_agent_request": - await this.handleCancelAgentRequest(msg.agentId); + await this.handleCancelAgentRequest(msg.agentId, msg.requestId); break; case "restart_server_request": @@ -1933,7 +1933,7 @@ export class Session { break; case "clear_agent_attention": - await this.handleClearAgentAttention(msg.agentId); + await this.handleClearAgentAttention(msg.agentId, msg.requestId); break; case "client_heartbeat": @@ -3189,11 +3189,23 @@ export class Session { } } - private async handleCancelAgentRequest(agentId: string): Promise { + private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise { this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`); try { await this.interruptAgentIfRunning(agentId); + if (requestId) { + const agent = this.agentManager.getAgent(agentId); + const payload = agent ? await this.buildAgentPayload(agent) : null; + this.emit({ + type: "cancel_agent_response", + payload: { + requestId, + agentId, + agent: payload, + }, + }); + } } catch (error) { this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request"); } @@ -3857,11 +3869,32 @@ export class Session { /** * Handle clearing agent attention flag */ - private async handleClearAgentAttention(agentId: string | string[]): Promise { + private async handleClearAgentAttention( + agentId: string | string[], + requestId?: string, + ): Promise { const agentIds = Array.isArray(agentId) ? agentId : [agentId]; try { await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id))); + if (requestId) { + const agents = ( + await Promise.all( + agentIds.map(async (id) => { + const agent = this.agentManager.getAgent(id); + return agent ? this.buildAgentPayload(agent) : null; + }), + ) + ).filter((payload): payload is NonNullable => payload !== null); + this.emit({ + type: "clear_agent_attention_response", + payload: { + requestId, + agentId, + agents, + }, + }); + } } catch (error: any) { this.sessionLogger.error({ err: error, agentIds }, "Failed to clear agent attention"); // Don't throw - this is not critical diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 63fdd7e6b..af2280dd7 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -876,6 +876,7 @@ export const RefreshAgentRequestMessageSchema = z.object({ export const CancelAgentRequestMessageSchema = z.object({ type: z.literal("cancel_agent_request"), agentId: z.string(), + requestId: z.string().optional(), }); export const RestartServerRequestMessageSchema = z.object({ @@ -1307,6 +1308,7 @@ export const FileDownloadTokenRequestSchema = z.object({ export const ClearAgentAttentionMessageSchema = z.object({ type: z.literal("clear_agent_attention"), agentId: z.union([z.string(), z.array(z.string())]), + requestId: z.string().optional(), }); export const ClientHeartbeatMessageSchema = z.object({ @@ -2102,6 +2104,24 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ }), }); +export const CancelAgentResponseMessageSchema = z.object({ + type: z.literal("cancel_agent_response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + agent: AgentSnapshotPayloadSchema.nullable(), + }), +}); + +export const ClearAgentAttentionResponseMessageSchema = z.object({ + type: z.literal("clear_agent_attention_response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string().or(z.array(z.string())), + agents: z.array(AgentSnapshotPayloadSchema), + }), +}); + export const SendAgentMessageResponseMessageSchema = z.object({ type: z.literal("send_agent_message_response"), payload: z.object({ @@ -2771,6 +2791,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, + CancelAgentResponseMessageSchema, + ClearAgentAttentionResponseMessageSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, GetDaemonConfigResponseMessageSchema, @@ -2883,6 +2905,7 @@ export type FetchAgentResponseMessage = z.infer; +export type CancelAgentResponseMessage = z.infer; export type SendAgentMessageResponseMessage = z.infer; export type SetVoiceModeResponseMessage = z.infer; export type SetAgentModeResponseMessage = z.infer; @@ -3048,6 +3071,9 @@ export type FileDownloadTokenResponse = z.infer; export type ShutdownServerRequestMessage = z.infer; export type ClearAgentAttentionMessage = z.infer; +export type ClearAgentAttentionResponseMessage = z.infer< + typeof ClearAgentAttentionResponseMessageSchema +>; export type ClientHeartbeatMessage = z.infer; export type ListCommandsRequest = z.infer; export type ListCommandsResponse = z.infer;