From 468bf25dcbfcfa6500b3a58a13bfcc4cf98e0097 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:53:21 +0900 Subject: [PATCH] fix(delegate-task): gate continuation recovery to canonical abort errors --- .../delegate-task/sync-continuation.test.ts | 13 +++++----- src/tools/delegate-task/sync-continuation.ts | 26 ++++++++++++++++++- src/tools/delegate-task/sync-task.test.ts | 14 +++++++--- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts index b33b7f721..0ddf5e7ed 100644 --- a/src/tools/delegate-task/sync-continuation.test.ts +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -186,7 +186,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) - test("recovers from pollSyncSession error when result already exists", async () => { + test("recovers from MessageAbortedError poll error when result already exists", async () => { const mockClient = { session: { messages: async () => ({ @@ -208,7 +208,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { const { executeSyncContinuation } = require("./sync-continuation") const deps = { - pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + pollSyncSession: async () => "MessageAbortedError: aborted by user", fetchSyncResult: async () => ({ ok: true as const, textContent: "Recovered result" }), } @@ -244,7 +244,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) - test("returns poll error when recovery fetch has no result", async () => { + test("returns MessageAbortedError poll error when recovery fetch has no result", async () => { const mockClient = { session: { messages: async () => ({ @@ -266,7 +266,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { const { executeSyncContinuation } = require("./sync-continuation") const deps = { - pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + pollSyncSession: async () => "MessageAbortedError: aborted by user", fetchSyncResult: async () => ({ ok: false as const, error: "No assistant response found" }), } @@ -296,7 +296,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { }, deps) //#then - expect(result).toBe("Task aborted.\n\nSession ID: ses_test_12345678") + expect(result).toBe("MessageAbortedError: aborted by user") expect(removeTaskCalls.length).toBe(1) expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) @@ -421,8 +421,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { //#then - removeTask should be called at least once (poller and finally may both call it) expect(removeTaskCalls.length).toBeGreaterThanOrEqual(1) expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") - expect(result).toContain("Task continued and completed in") - expect(result).toContain("Result") + expect(result).toBe("Task aborted.\n\nSession ID: ses_test_12345678") }) test("no crash when toastManager is null", async () => { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 36679981f..6e600616d 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -24,6 +24,28 @@ type ResumeContext = { anchorMessageCount?: number } +function shouldAttemptPollErrorRecovery(pollError: string): boolean { + const trimmed = pollError.trim() + + if (trimmed.length === 0) { + return false + } + + if (/\bMessageAbortedError\b/u.test(trimmed)) { + return true + } + + if (/\bDOMException\b/u.test(trimmed) && /\bAbortError\b/u.test(trimmed)) { + return true + } + + if (/\bAbortError\b/u.test(trimmed) && !/\bTask aborted\b/u.test(trimmed)) { + return true + } + + return false +} + async function resolveResumeContext( client: ExecutorContext["client"], continuationID: string @@ -161,7 +183,7 @@ export async function executeSyncContinuation( taskId, anchorMessageCount, }, syncPollTimeoutMs) - if (pollError) { + if (pollError && shouldAttemptPollErrorRecovery(pollError)) { const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) if (!recoveredResult.ok) { return pollError @@ -181,6 +203,8 @@ ${buildTaskMetadataBlock({ agent: resumeAgent, category: args.category, })}` + } else if (pollError) { + return pollError } const result = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 03e2d500a..0143885dd 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -275,7 +275,7 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(fetchSyncResultCalled).toBe(false) }) - test("returns poll error when recovery fetch has no result", async () => { + test("returns abort poll error when recovery fetch has no result", async () => { const mockClient = { session: { create: async () => ({ data: { id: "ses_test_12345678" } }), @@ -284,11 +284,16 @@ describe("executeSyncTask - cleanup on error paths", () => { const { executeSyncTask } = require("./sync-task") + let fetchSyncResultCalled = false + const deps = { createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), sendSyncPrompt: async () => null, - pollSyncSession: async () => "Poll error", - fetchSyncResult: async () => ({ ok: false as const, error: "No assistant response found" }), + pollSyncSession: async () => "MessageAbortedError: aborted by user", + fetchSyncResult: async () => { + fetchSyncResultCalled = true + return { ok: false as const, error: "No assistant response found" } + }, } const mockCtx = { @@ -318,7 +323,8 @@ describe("executeSyncTask - cleanup on error paths", () => { }, "test-agent", undefined, undefined, undefined, undefined, deps) //#then - expect(result).toBe("Poll error") + expect(result).toBe("MessageAbortedError: aborted by user") + expect(fetchSyncResultCalled).toBe(true) expect(removeTaskCalls.length).toBe(1) expect(deleteCalls.length).toBe(1) })