fix: keep sent Claw messages out of composer

This commit is contained in:
134 2026-07-27 10:22:03 +08:00
parent c889bfc381
commit 50f34e7a0f
5 changed files with 84 additions and 49 deletions

View File

@ -15,7 +15,6 @@ import {
} from "@/lib/openclaw-chat/config"; } from "@/lib/openclaw-chat/config";
import { import {
buildClawdCancelKey, buildClawdCancelKey,
cancelClawdRequest,
clearClawdRequestCancellation, clearClawdRequestCancellation,
isClawdRequestCanceled, isClawdRequestCanceled,
registerActiveClawdRequest, registerActiveClawdRequest,
@ -259,10 +258,6 @@ export async function POST(request: Request) {
const controller = new AbortController(); const controller = new AbortController();
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs); registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
const timeout = setTimeout(() => controller.abort(), 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, { const response = await fetch(endpoint, {
method: "POST", method: "POST",
headers: { headers: {
@ -302,7 +297,6 @@ export async function POST(request: Request) {
signal: controller.signal, signal: controller.signal,
}).finally(() => { }).finally(() => {
clearTimeout(timeout); clearTimeout(timeout);
request.signal.removeEventListener("abort", handleRequestAbort);
unregisterActiveClawdRequest(cancelKey); unregisterActiveClawdRequest(cancelKey);
}); });

View File

@ -101,6 +101,10 @@ import {
normalizeCanvasSkillTags, normalizeCanvasSkillTags,
type CanvasSkillTag, type CanvasSkillTag,
} from "@/lib/canvas-skill-tags"; } from "@/lib/canvas-skill-tags";
import {
isClawdTransportInterruption,
shouldRestoreClawdComposerAfterFailure,
} from "@/lib/openclaw-chat/request-lifecycle";
import { import {
isCanvasImageReadyForAi, isCanvasImageReadyForAi,
isCanvasImageUploadPending, isCanvasImageUploadPending,
@ -11199,8 +11203,7 @@ export function CanvasChatPanel({
}); });
const responseData = await response.json().catch(() => null); const responseData = await response.json().catch(() => null);
if (response.status === 499 || responseData?.canceled === true) { if (response.status === 499 || responseData?.canceled === true) {
canceledClawdMessageIdsRef.current.add(userMessageId); throw new Error("Claw request interrupted");
throw new DOMException("Claw request canceled", "AbortError");
} }
if (!response.ok) { if (!response.ok) {
console.error("[CanvasChat] Claw API returned error:", { console.error("[CanvasChat] Claw API returned error:", {
@ -11251,10 +11254,13 @@ export function CanvasChatPanel({
void mutateClawdHistory(); void mutateClawdHistory();
hasHydratedHistoryRef.current = true; hasHydratedHistoryRef.current = true;
} catch (error) { } catch (error) {
const wasCanceled = const explicitlyCanceled =
canceledClawdMessageIdsRef.current.has(userMessageId) || canceledClawdMessageIdsRef.current.has(userMessageId);
(error instanceof Error && error.name === "AbortError"); if (
if (wasCanceled) { shouldRestoreClawdComposerAfterFailure({
explicitlyCanceled,
})
) {
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) { if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
pendingClawdRequestRestoreRef.current = null; pendingClawdRequestRestoreRef.current = null;
restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot); restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot);
@ -11263,44 +11269,11 @@ export function CanvasChatPanel({
return; return;
} }
console.error("[CanvasChat] Claw request failed:", error); console.error("[CanvasChat] Claw request failed:", error);
pendingClawdSubmissionRef.current = null; toast.error(
if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) { isClawdTransportInterruption(error)
recentClawdSubmissionRef.current = null; ? "连接已中断,任务仍可能在后台处理,请稍后查看 Claw 历史"
writeStoredClawdComposerSubmissionSnapshot(null); : CLAW_UPGRADE_RETRY_MESSAGE
}
setMessages((previousMessages) =>
previousMessages.filter((message) => message.id !== userMessageId)
); );
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 { } finally {
pendingClawdSubmissionRef.current = null; pendingClawdSubmissionRef.current = null;
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) { if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {

View File

@ -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)
);
}

View File

@ -13,6 +13,7 @@ export default defineConfig({
/routes\/canvas-scale-adjust-intent\.test\.ts/, /routes\/canvas-scale-adjust-intent\.test\.ts/,
/routes\/canvas-runtime-internal-endpoint\.test\.ts/, /routes\/canvas-runtime-internal-endpoint\.test\.ts/,
/routes\/model-classification\.test\.ts/, /routes\/model-classification\.test\.ts/,
/routes\/clawd-request-lifecycle\.test\.ts/,
], ],
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,

View File

@ -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")'
);
});
});