From 4b14faf825a9ccc975c3b0d9f23b98ce45718945 Mon Sep 17 00:00:00 2001 From: Ido Frizler Date: Sat, 28 Feb 2026 15:27:46 +0200 Subject: [PATCH] fix: refresh tool stall timer on tool heartbeats Treat SDK progress/partial-result events as activity so long-running tool calls are not falsely aborted as stalled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/main.ts | 21 +++++++++++++++++++++ src/main/utils/toolStallHeartbeat.test.ts | 15 +++++++++++++++ src/main/utils/toolStallHeartbeat.ts | 3 +++ 3 files changed, 39 insertions(+) create mode 100644 src/main/utils/toolStallHeartbeat.test.ts create mode 100644 src/main/utils/toolStallHeartbeat.ts diff --git a/src/main/main.ts b/src/main/main.ts index 26b61c3..1245a50 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -96,6 +96,7 @@ import { extractFilesToDelete, } from './utils/extractExecutables'; import { createPermissionRequestId } from './utils/permissionRequest'; +import { isToolStallHeartbeatEventType } from './utils/toolStallHeartbeat'; import { validateCopilotCreateSessionArgs, validateCopilotResumePreviousSessionArgs, @@ -864,6 +865,10 @@ function registerSessionEventForwarding(sessionId: string, session: CopilotSessi toolName: event.data.toolName, input: event.data.arguments || (event.data as Record), }); + } else if (isToolStallHeartbeatEventType(event.type)) { + // SDK emits periodic progress/partial-result events while a tool is still active. + // Treat these as heartbeat signals so long-running tools don't trip the stall timeout. + refreshToolStallTimer(sessionId); } else if (event.type === 'tool.execution_complete') { log.debug(`[${sessionId}] Tool end: ${event.data.toolCallId}`); clearToolStallTimer(sessionId); @@ -1009,11 +1014,14 @@ function stopKeepAlive(): void { // Tool execution stall detection - auto-abort sessions with hung tool calls const TOOL_STALL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const sessionToolStallTimers = new Map(); +const sessionActiveToolNames = new Map(); function startToolStallTimer(sessionId: string, toolName: string): void { clearToolStallTimer(sessionId); + sessionActiveToolNames.set(sessionId, toolName); const timer = setTimeout(async () => { sessionToolStallTimers.delete(sessionId); + sessionActiveToolNames.delete(sessionId); const sessionState = sessions.get(sessionId); if (!sessionState) return; log.warn( @@ -1044,12 +1052,19 @@ function startToolStallTimer(sessionId: string, toolName: string): void { sessionToolStallTimers.set(sessionId, timer); } +function refreshToolStallTimer(sessionId: string): void { + const activeToolName = sessionActiveToolNames.get(sessionId); + if (!activeToolName) return; + startToolStallTimer(sessionId, activeToolName); +} + function clearToolStallTimer(sessionId: string): void { const timer = sessionToolStallTimers.get(sessionId); if (timer) { clearTimeout(timer); sessionToolStallTimers.delete(sessionId); } + sessionActiveToolNames.delete(sessionId); } // Resume a session that has been disconnected @@ -1258,6 +1273,8 @@ async function startEarlySessionResumption(): Promise { toolName: event.data.toolName, input: event.data.arguments || (event.data as Record), }); + } else if (isToolStallHeartbeatEventType(event.type)) { + refreshToolStallTimer(sessionId); } else if (event.type === 'tool.execution_complete') { log.debug(`[${sessionId}] Tool end: ${event.data.toolCallId}`); clearToolStallTimer(sessionId); @@ -2435,6 +2452,8 @@ async function initCopilot(): Promise { toolName: event.data.toolName, input: event.data.arguments || (event.data as Record), }); + } else if (isToolStallHeartbeatEventType(event.type)) { + refreshToolStallTimer(sessionId); } else if (event.type === 'tool.execution_complete') { log.debug(`[${sessionId}] Tool end: ${event.data.toolCallId}`); clearToolStallTimer(sessionId); @@ -5054,6 +5073,8 @@ ipcMain.handle( toolName: event.data.toolName, input: event.data.arguments || (event.data as Record), }); + } else if (isToolStallHeartbeatEventType(event.type)) { + refreshToolStallTimer(sessionId); } else if (event.type === 'tool.execution_complete') { log.debug(`[${sessionId}] Tool end: ${event.data.toolCallId}`); clearToolStallTimer(sessionId); diff --git a/src/main/utils/toolStallHeartbeat.test.ts b/src/main/utils/toolStallHeartbeat.test.ts new file mode 100644 index 0000000..917d77f --- /dev/null +++ b/src/main/utils/toolStallHeartbeat.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { isToolStallHeartbeatEventType } from './toolStallHeartbeat'; + +describe('isToolStallHeartbeatEventType', () => { + it('treats periodic tool activity events as heartbeats', () => { + expect(isToolStallHeartbeatEventType('tool.execution_progress')).toBe(true); + expect(isToolStallHeartbeatEventType('tool.execution_partial_result')).toBe(true); + }); + + it('does not treat start/complete events as heartbeats', () => { + expect(isToolStallHeartbeatEventType('tool.execution_start')).toBe(false); + expect(isToolStallHeartbeatEventType('tool.execution_complete')).toBe(false); + expect(isToolStallHeartbeatEventType('session.idle')).toBe(false); + }); +}); diff --git a/src/main/utils/toolStallHeartbeat.ts b/src/main/utils/toolStallHeartbeat.ts new file mode 100644 index 0000000..8ed4deb --- /dev/null +++ b/src/main/utils/toolStallHeartbeat.ts @@ -0,0 +1,3 @@ +export function isToolStallHeartbeatEventType(eventType: string): boolean { + return eventType === 'tool.execution_progress' || eventType === 'tool.execution_partial_result'; +}