From 50f34e7a0f7fe18a7030d511d8a02980e8d0501d Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 27 Jul 2026 10:22:03 +0800 Subject: [PATCH] fix: keep sent Claw messages out of composer --- app/api/canvas/clawd-chat/route.ts | 6 -- components/canvas/CanvasChatPanel.tsx | 59 ++++++-------------- lib/openclaw-chat/request-lifecycle.ts | 16 ++++++ playwright.helpers.config.ts | 1 + tests/routes/clawd-request-lifecycle.test.ts | 51 +++++++++++++++++ 5 files changed, 84 insertions(+), 49 deletions(-) create mode 100644 lib/openclaw-chat/request-lifecycle.ts create mode 100644 tests/routes/clawd-request-lifecycle.test.ts diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index 01479493a..d360ece3e 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -15,7 +15,6 @@ import { } from "@/lib/openclaw-chat/config"; import { buildClawdCancelKey, - cancelClawdRequest, clearClawdRequestCancellation, isClawdRequestCanceled, registerActiveClawdRequest, @@ -259,10 +258,6 @@ export async function POST(request: Request) { const controller = new AbortController(); registerActiveClawdRequest(cancelKey, controller, config.timeoutMs); const timeout = setTimeout(() => controller.abort(), config.timeoutMs); - const handleRequestAbort = () => { - cancelClawdRequest(cancelKey); - }; - request.signal.addEventListener("abort", handleRequestAbort, { once: true }); const response = await fetch(endpoint, { method: "POST", headers: { @@ -302,7 +297,6 @@ export async function POST(request: Request) { signal: controller.signal, }).finally(() => { clearTimeout(timeout); - request.signal.removeEventListener("abort", handleRequestAbort); unregisterActiveClawdRequest(cancelKey); }); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 8c35ab4a2..5f8f3529d 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -101,6 +101,10 @@ import { normalizeCanvasSkillTags, type CanvasSkillTag, } from "@/lib/canvas-skill-tags"; +import { + isClawdTransportInterruption, + shouldRestoreClawdComposerAfterFailure, +} from "@/lib/openclaw-chat/request-lifecycle"; import { isCanvasImageReadyForAi, isCanvasImageUploadPending, @@ -11199,8 +11203,7 @@ export function CanvasChatPanel({ }); const responseData = await response.json().catch(() => null); if (response.status === 499 || responseData?.canceled === true) { - canceledClawdMessageIdsRef.current.add(userMessageId); - throw new DOMException("Claw request canceled", "AbortError"); + throw new Error("Claw request interrupted"); } if (!response.ok) { console.error("[CanvasChat] Claw API returned error:", { @@ -11251,10 +11254,13 @@ export function CanvasChatPanel({ void mutateClawdHistory(); hasHydratedHistoryRef.current = true; } catch (error) { - const wasCanceled = - canceledClawdMessageIdsRef.current.has(userMessageId) || - (error instanceof Error && error.name === "AbortError"); - if (wasCanceled) { + const explicitlyCanceled = + canceledClawdMessageIdsRef.current.has(userMessageId); + if ( + shouldRestoreClawdComposerAfterFailure({ + explicitlyCanceled, + }) + ) { if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) { pendingClawdRequestRestoreRef.current = null; restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot); @@ -11263,44 +11269,11 @@ export function CanvasChatPanel({ return; } console.error("[CanvasChat] Claw request failed:", error); - pendingClawdSubmissionRef.current = null; - if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) { - recentClawdSubmissionRef.current = null; - writeStoredClawdComposerSubmissionSnapshot(null); - } - setMessages((previousMessages) => - previousMessages.filter((message) => message.id !== userMessageId) + toast.error( + isClawdTransportInterruption(error) + ? "连接已中断,任务仍可能在后台处理,请稍后查看 Claw 历史" + : CLAW_UPGRADE_RETRY_MESSAGE ); - toast.error(CLAW_UPGRADE_RETRY_MESSAGE); - if (!isProgrammaticClawdQuickSend) { - setClawdTableFiles(tableFilesSnapshot); - setAttachments(attachmentsSnapshot); - if (clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt) { - setSelectedSkillTagId(clawdSkillTagForRequest.id); - pendingSelectedClawdSkillTagRef.current = clawdSkillTagForRequest; - } - const editableDiv = textareaRef.current as HTMLDivElement | null; - const currentEditorText = (editableDiv?.textContent || "") - .replace(/\u200b/g, " ") - .replace(/\s+/g, " ") - .trim(); - if (!currentEditorText) { - setInput(textContent); - if (editableDiv) { - editableDiv.textContent = textContent; - window.setTimeout(() => { - attachmentsSnapshot.forEach((attachment, index) => { - if (!attachment.url) return; - insertImageTagInEditor( - attachment.url, - normalizeChatAttachmentName(attachment.name), - index - ); - }); - }, 0); - } - } - } } finally { pendingClawdSubmissionRef.current = null; if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) { diff --git a/lib/openclaw-chat/request-lifecycle.ts b/lib/openclaw-chat/request-lifecycle.ts new file mode 100644 index 000000000..91bbc9f08 --- /dev/null +++ b/lib/openclaw-chat/request-lifecycle.ts @@ -0,0 +1,16 @@ +export function shouldRestoreClawdComposerAfterFailure({ + explicitlyCanceled, +}: { + explicitlyCanceled: boolean; +}) { + return explicitlyCanceled; +} + +export function isClawdTransportInterruption(error: unknown) { + if (!(error instanceof Error)) return false; + + return ( + error.name === "AbortError" || + /aborted|failed to fetch|fetch failed|network|interrupted/i.test(error.message) + ); +} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 6862b52e1..1c10d4ef4 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ /routes\/canvas-scale-adjust-intent\.test\.ts/, /routes\/canvas-runtime-internal-endpoint\.test\.ts/, /routes\/model-classification\.test\.ts/, + /routes\/clawd-request-lifecycle\.test\.ts/, ], fullyParallel: true, forbidOnly: !!process.env.CI, diff --git a/tests/routes/clawd-request-lifecycle.test.ts b/tests/routes/clawd-request-lifecycle.test.ts new file mode 100644 index 000000000..e501f02ae --- /dev/null +++ b/tests/routes/clawd-request-lifecycle.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { expect, test } from "@playwright/test"; +import { + isClawdTransportInterruption, + shouldRestoreClawdComposerAfterFailure, +} from "@/lib/openclaw-chat/request-lifecycle"; + +function readSource(path: string) { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +test.describe("Claw request lifecycle", () => { + test("restores the composer only after an explicit user cancellation", () => { + expect( + shouldRestoreClawdComposerAfterFailure({ explicitlyCanceled: true }) + ).toBe(true); + expect( + shouldRestoreClawdComposerAfterFailure({ explicitlyCanceled: false }) + ).toBe(false); + }); + + test("recognizes browser and network interruptions without treating them as edits", () => { + expect( + isClawdTransportInterruption( + new DOMException("The operation was aborted", "AbortError") + ) + ).toBe(true); + expect(isClawdTransportInterruption(new Error("fetch failed"))).toBe(true); + expect(isClawdTransportInterruption(new Error("provider unavailable"))).toBe( + false + ); + }); + + test("does not cancel the upstream request when the browser connection closes", () => { + const source = readSource("app/api/canvas/clawd-chat/route.ts"); + + expect(source).not.toContain('request.signal.addEventListener("abort"'); + expect(source).not.toContain("cancelClawdRequest(cancelKey)"); + expect(source).toContain("registerActiveClawdRequest(cancelKey"); + }); + + test("does not classify every AbortError as an explicit stop action", () => { + const source = readSource("components/canvas/CanvasChatPanel.tsx"); + + expect(source).toContain("shouldRestoreClawdComposerAfterFailure"); + expect(source).not.toContain( + '(error instanceof Error && error.name === "AbortError")' + ); + }); +});