From 4ab93c0cf7cf58b1ba7cd3057b2d0ce05c3adc70 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 14 Feb 2026 13:32:17 +0900 Subject: [PATCH] fix: refresh lastUpdate on all message.part.updated events, not just tool events Reasoning/thinking models (Oracle, Claude Opus) were being killed by the stale timeout because lastUpdate was only refreshed on tool-type events. During extended thinking, no tool events fire, so after 3 minutes the task was incorrectly marked as stale and aborted. Move progress initialization and lastUpdate refresh before the tool-type conditional so any message.part.updated event (text, thinking, tool) keeps the task alive. --- .../background-event-handler.ts | 9 +- src/features/background-agent/manager.test.ts | 158 ++++++++++++++++++ src/features/background-agent/manager.ts | 15 +- 3 files changed, 171 insertions(+), 11 deletions(-) diff --git a/src/features/background-agent/background-event-handler.ts b/src/features/background-agent/background-event-handler.ts index 67bd7705..1f909b67 100644 --- a/src/features/background-agent/background-event-handler.ts +++ b/src/features/background-agent/background-event-handler.ts @@ -69,13 +69,14 @@ export function handleBackgroundEvent(args: { const type = getString(props, "type") const tool = getString(props, "tool") + if (!task.progress) { + task.progress = { toolCalls: 0, lastUpdate: new Date() } + } + task.progress.lastUpdate = new Date() + if (type === "tool" || tool) { - if (!task.progress) { - task.progress = { toolCalls: 0, lastUpdate: new Date() } - } task.progress.toolCalls += 1 task.progress.lastTool = tool - task.progress.lastUpdate = new Date() } } diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 03d607dd..d8eba098 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -3045,3 +3045,161 @@ describe("BackgroundManager.handleEvent - early session.idle deferral", () => { } }) }) + +describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => { + test("should update lastUpdate on text-type message.part.updated event", () => { + //#given - a running task with stale lastUpdate + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + + const oldUpdate = new Date(Date.now() - 300_000) + const task: BackgroundTask = { + id: "task-text-1", + sessionID: "session-text-1", + parentSessionID: "parent-1", + parentMessageID: "msg-1", + description: "Thinking task", + prompt: "Think deeply", + agent: "oracle", + status: "running", + startedAt: new Date(Date.now() - 600_000), + progress: { + toolCalls: 2, + lastUpdate: oldUpdate, + }, + } + getTaskMap(manager).set(task.id, task) + + //#when - a text-type message.part.updated event arrives + manager.handleEvent({ + type: "message.part.updated", + properties: { sessionID: "session-text-1", type: "text" }, + }) + + //#then - lastUpdate should be refreshed, toolCalls should NOT change + expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime()) + expect(task.progress!.toolCalls).toBe(2) + }) + + test("should update lastUpdate on thinking-type message.part.updated event", () => { + //#given - a running task with stale lastUpdate + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + + const oldUpdate = new Date(Date.now() - 300_000) + const task: BackgroundTask = { + id: "task-thinking-1", + sessionID: "session-thinking-1", + parentSessionID: "parent-1", + parentMessageID: "msg-1", + description: "Reasoning task", + prompt: "Reason about architecture", + agent: "oracle", + status: "running", + startedAt: new Date(Date.now() - 600_000), + progress: { + toolCalls: 0, + lastUpdate: oldUpdate, + }, + } + getTaskMap(manager).set(task.id, task) + + //#when - a thinking-type message.part.updated event arrives + manager.handleEvent({ + type: "message.part.updated", + properties: { sessionID: "session-thinking-1", type: "thinking" }, + }) + + //#then - lastUpdate should be refreshed, toolCalls should remain 0 + expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime()) + expect(task.progress!.toolCalls).toBe(0) + }) + + test("should initialize progress on first non-tool event", () => { + //#given - a running task with NO progress field + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + + const task: BackgroundTask = { + id: "task-init-1", + sessionID: "session-init-1", + parentSessionID: "parent-1", + parentMessageID: "msg-1", + description: "New task", + prompt: "Start thinking", + agent: "oracle", + status: "running", + startedAt: new Date(Date.now() - 60_000), + } + getTaskMap(manager).set(task.id, task) + + //#when - a text-type event arrives before any tool call + manager.handleEvent({ + type: "message.part.updated", + properties: { sessionID: "session-init-1", type: "text" }, + }) + + //#then - progress should be initialized with toolCalls: 0 and fresh lastUpdate + expect(task.progress).toBeDefined() + expect(task.progress!.toolCalls).toBe(0) + expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(Date.now() - 5000) + }) + + test("should NOT mark thinking model as stale when text events refresh lastUpdate", async () => { + //#given - a running task where text events keep lastUpdate fresh + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 }) + stubNotifyParentSession(manager) + + const task: BackgroundTask = { + id: "task-alive-1", + sessionID: "session-alive-1", + parentSessionID: "parent-1", + parentMessageID: "msg-1", + description: "Long thinking task", + prompt: "Deep reasoning", + agent: "oracle", + status: "running", + startedAt: new Date(Date.now() - 600_000), + progress: { + toolCalls: 0, + lastUpdate: new Date(Date.now() - 300_000), + }, + } + getTaskMap(manager).set(task.id, task) + + //#when - a text event arrives, then stale check runs + manager.handleEvent({ + type: "message.part.updated", + properties: { sessionID: "session-alive-1", type: "text" }, + }) + await manager["checkAndInterruptStaleTasks"]() + + //#then - task should still be running (text event refreshed lastUpdate) + expect(task.status).toBe("running") + }) +}) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 199b58e4..b2d4a40a 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -662,16 +662,17 @@ export class BackgroundManager { this.idleDeferralTimers.delete(task.id) } - if (partInfo?.type === "tool" || partInfo?.tool) { - if (!task.progress) { - task.progress = { - toolCalls: 0, - lastUpdate: new Date(), - } + if (!task.progress) { + task.progress = { + toolCalls: 0, + lastUpdate: new Date(), } + } + task.progress.lastUpdate = new Date() + + if (partInfo?.type === "tool" || partInfo?.tool) { task.progress.toolCalls += 1 task.progress.lastTool = partInfo.tool - task.progress.lastUpdate = new Date() } }