From 8b1696f5e57471455cf9fcfe7a995e3fff18e012 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:20:14 +0900 Subject: [PATCH] fix(delegate-task): prevent stale-text abort recovery --- src/tools/delegate-task/sync-continuation.ts | 4 +- .../delegate-task/sync-result-fetcher.test.ts | 61 +++++++++++++++++++ .../delegate-task/sync-result-fetcher.ts | 23 ++++++- src/tools/delegate-task/sync-task.ts | 4 +- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 817a7e95d..d2dd3b678 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -191,7 +191,9 @@ export async function executeSyncContinuation( if (anchorMessageCount === undefined) { return pollError } - const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) + const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount, { + strictAbortRecovery: true, + }) if (!recoveredResult.ok) { return pollError } diff --git a/src/tools/delegate-task/sync-result-fetcher.test.ts b/src/tools/delegate-task/sync-result-fetcher.test.ts index 82a41f3f6..400c066ad 100644 --- a/src/tools/delegate-task/sync-result-fetcher.test.ts +++ b/src/tools/delegate-task/sync-result-fetcher.test.ts @@ -141,4 +141,65 @@ describe("fetchSyncResult", () => { expect(result.ok).toBe(false) expect(result.error).toContain("No assistant response found") }) + + test("strict abort recovery: does not fall back to older text when latest assistant is error", async () => { + //#given + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "Older text" }], + }, + { + info: { + id: "msg_003", + role: "assistant", + time: { created: 3000 }, + error: { name: "MessageAbortedError", message: "The operation was aborted." }, + }, + parts: [], + }, + ], + }), + }, + } + + //#when + const result = await fetchSyncResult(mockClient, "ses_test", 1, { strictAbortRecovery: true }) + + //#then + expect(result.ok).toBe(false) + expect(result.error).toContain("Latest assistant message is an error") + }) + + test("strict abort recovery: requires latest assistant text output", async () => { + //#given + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "tool", toolCallId: "t1", toolName: "x", state: "output-available", input: {}, output: {} }], + }, + ], + }), + }, + } + + //#when + const result = await fetchSyncResult(mockClient, "ses_test", 0, { strictAbortRecovery: true }) + + //#then + expect(result.ok).toBe(false) + expect(result.error).toContain("No assistant text output found in latest response") + }) }) diff --git a/src/tools/delegate-task/sync-result-fetcher.ts b/src/tools/delegate-task/sync-result-fetcher.ts index 9631d3637..f236d6724 100644 --- a/src/tools/delegate-task/sync-result-fetcher.ts +++ b/src/tools/delegate-task/sync-result-fetcher.ts @@ -5,7 +5,8 @@ import { normalizeSDKResponse } from "../../shared" export async function fetchSyncResult( client: OpencodeClient, sessionID: string, - anchorMessageCount?: number + anchorMessageCount?: number, + options?: { strictAbortRecovery?: boolean } ): Promise<{ ok: true; textContent: string } | { ok: false; error: string }> { const messagesResult = await client.session.messages({ path: { id: sessionID }, @@ -44,6 +45,26 @@ export async function fetchSyncResult( return { ok: false, error: `No assistant response found.\n\nSession ID: ${sessionID}` } } + if (options?.strictAbortRecovery) { + if (lastMessage.info && "error" in lastMessage.info) { + return { + ok: false, + error: `Latest assistant message is an error; refusing abort recovery.\n\nSession ID: ${sessionID}`, + } + } + + const lastTextParts = lastMessage.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] + const lastContent = lastTextParts.map((p) => p.text ?? "").filter(Boolean).join("\n") + if (!lastContent) { + return { + ok: false, + error: `No assistant text output found in latest response.\n\nSession ID: ${sessionID}`, + } + } + + return { ok: true, textContent: lastContent } + } + // Search assistant messages (newest first) for one with text/reasoning content. // The last assistant message may only contain tool calls with no text. let textContent = "" diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 556ca8225..dcc4b31e8 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -240,7 +240,9 @@ export async function executeSyncTask( }, syncPollTimeoutMs) if (pollError) { if (shouldAttemptPollErrorRecovery(pollError)) { - const recoveredResult = await deps.fetchSyncResult(client, activeSessionID) + const recoveredResult = await deps.fetchSyncResult(client, activeSessionID, undefined, { + strictAbortRecovery: true, + }) if (recoveredResult.ok) { const duration = formatDuration(startTime)