Unverified 提交 cde2a205 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Exclude canceled messages (#3099)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3099" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 de8ab4db
import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
import fs from "fs";
test("cancelled message shows cancelled indicator and is excluded from context", async ({
po,
}) => {
await po.setUp();
// Send a message with a slow response so we have time to cancel
await po.sendPrompt("tc=cancelled-test [sleep=medium]", {
skipWaitForCompletion: true,
});
// Click the cancel generation button
await po.page.getByRole("button", { name: "Cancel generation" }).click();
// Wait for streaming to stop (Retry button appears)
await po.chatActions.waitForChatCompletion();
// Verify the "Cancelled" indicators are visible (one on user msg, one on assistant msg)
const messagesList = po.page.getByTestId("messages-list");
const cancelledIndicators = messagesList.getByText("Cancelled", {
exact: true,
});
await expect(cancelledIndicators).toHaveCount(2);
// Send a follow-up message with [dump] to capture what gets sent to the LLM
await po.sendPrompt("[dump] tc=follow-up");
// The follow-up should be visible
await expect(messagesList.getByText("tc=follow-up")).toBeVisible();
// Cancelled indicators should still be visible (messages stay in UI)
await expect(cancelledIndicators).toHaveCount(2);
// Read the server dump to verify the cancelled message is NOT in the context
const messagesListText = await messagesList.textContent();
const dumpPathMatch = messagesListText?.match(
/\[\[dyad-dump-path=([^\]]+)\]\]/,
);
expect(dumpPathMatch).toBeTruthy();
const dumpContent = fs.readFileSync(dumpPathMatch![1], "utf-8");
expect(dumpContent).not.toContain("tc=cancelled-test");
expect(dumpContent).not.toContain("Response cancelled by user");
});
{ {
"name": "dyad", "name": "dyad",
"version": "0.40.0", "version": "0.41.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dyad", "name": "dyad",
"version": "0.40.0", "version": "0.41.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.46", "@ai-sdk/amazon-bedrock": "^4.0.46",
......
import { describe, it, expect } from "vitest";
import {
isCancelledResponseContent,
appendCancelledResponseNotice,
stripCancelledResponseNotice,
applyCancellationNoticeToLastAssistantMessage,
filterCancelledMessagePairs,
} from "@/shared/chatCancellation";
describe("chatCancellation", () => {
describe("isCancelledResponseContent", () => {
it("should return true for content ending with cancellation notice", () => {
expect(
isCancelledResponseContent("Some text\n\n[Response cancelled by user]"),
).toBe(true);
});
it("should return true for only the cancellation notice", () => {
expect(isCancelledResponseContent("[Response cancelled by user]")).toBe(
true,
);
});
it("should return true with trailing whitespace", () => {
expect(
isCancelledResponseContent("[Response cancelled by user] "),
).toBe(true);
});
it("should return false for empty string", () => {
expect(isCancelledResponseContent("")).toBe(false);
});
it("should return false for regular content", () => {
expect(isCancelledResponseContent("Hello world")).toBe(false);
});
it("should return false for partial match", () => {
expect(isCancelledResponseContent("[Response cancelled")).toBe(false);
});
});
describe("appendCancelledResponseNotice", () => {
it("should append notice to content", () => {
expect(appendCancelledResponseNotice("Some text")).toBe(
"Some text\n\n[Response cancelled by user]",
);
});
it("should return just the notice for empty string", () => {
expect(appendCancelledResponseNotice("")).toBe(
"[Response cancelled by user]",
);
});
it("should be idempotent - calling twice returns same result", () => {
const once = appendCancelledResponseNotice("Some text");
const twice = appendCancelledResponseNotice(once);
expect(twice).toBe(once);
});
it("should trim trailing whitespace before appending", () => {
expect(appendCancelledResponseNotice("Some text ")).toBe(
"Some text\n\n[Response cancelled by user]",
);
});
it("should return just the notice for whitespace-only string", () => {
expect(appendCancelledResponseNotice(" ")).toBe(
"[Response cancelled by user]",
);
});
});
describe("stripCancelledResponseNotice", () => {
it("should strip the notice from content", () => {
expect(
stripCancelledResponseNotice(
"Some text\n\n[Response cancelled by user]",
),
).toBe("Some text");
});
it("should return empty string when content is only the notice", () => {
expect(stripCancelledResponseNotice("[Response cancelled by user]")).toBe(
"",
);
});
it("should return original content when no notice present", () => {
expect(stripCancelledResponseNotice("Hello world")).toBe("Hello world");
});
it("should handle trailing whitespace after notice", () => {
expect(
stripCancelledResponseNotice(
"Some text\n\n[Response cancelled by user] ",
),
).toBe("Some text");
});
it("should return empty string for empty input", () => {
expect(stripCancelledResponseNotice("")).toBe("");
});
it("should roundtrip with appendCancelledResponseNotice", () => {
const original = "Hello world";
const withNotice = appendCancelledResponseNotice(original);
const stripped = stripCancelledResponseNotice(withNotice);
expect(stripped).toBe(original);
});
});
describe("applyCancellationNoticeToLastAssistantMessage", () => {
it("should add notice to last assistant message", () => {
const messages = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
];
const result = applyCancellationNoticeToLastAssistantMessage(messages);
expect(result[1].content).toBe(
"Hi there\n\n[Response cancelled by user]",
);
});
it("should not modify original array", () => {
const messages = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
];
applyCancellationNoticeToLastAssistantMessage(messages);
expect(messages[1].content).toBe("Hi there");
});
it("should return same array when no assistant messages", () => {
const messages = [{ role: "user", content: "Hello" }];
const result = applyCancellationNoticeToLastAssistantMessage(messages);
expect(result).toBe(messages);
});
it("should return same array when already cancelled", () => {
const messages = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: "Hi\n\n[Response cancelled by user]",
},
];
const result = applyCancellationNoticeToLastAssistantMessage(messages);
expect(result).toBe(messages);
});
it("should handle empty messages array", () => {
const result = applyCancellationNoticeToLastAssistantMessage([]);
expect(result).toEqual([]);
});
});
describe("filterCancelledMessagePairs", () => {
it("should filter out cancelled assistant messages", () => {
const messages = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: "Hi\n\n[Response cancelled by user]",
},
{ role: "user", content: "Try again" },
{ role: "assistant", content: "Hello!" },
];
const result = filterCancelledMessagePairs(messages);
expect(result).toEqual([
{ role: "user", content: "Try again" },
{ role: "assistant", content: "Hello!" },
]);
});
it("should filter the preceding user message of a cancelled response", () => {
const messages = [
{ role: "user", content: "First question" },
{
role: "assistant",
content: "[Response cancelled by user]",
},
];
const result = filterCancelledMessagePairs(messages);
expect(result).toEqual([]);
});
it("should not filter non-cancelled messages", () => {
const messages = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
];
const result = filterCancelledMessagePairs(messages);
expect(result).toEqual(messages);
});
it("should handle empty array", () => {
expect(filterCancelledMessagePairs([])).toEqual([]);
});
it("should handle multiple cancelled pairs", () => {
const messages = [
{ role: "user", content: "Q1" },
{
role: "assistant",
content: "A1\n\n[Response cancelled by user]",
},
{ role: "user", content: "Q2" },
{
role: "assistant",
content: "[Response cancelled by user]",
},
{ role: "user", content: "Q3" },
{ role: "assistant", content: "A3" },
];
const result = filterCancelledMessagePairs(messages);
expect(result).toEqual([
{ role: "user", content: "Q3" },
{ role: "assistant", content: "A3" },
]);
});
});
});
...@@ -1362,7 +1362,7 @@ describe("handleLocalAgentStream", () => { ...@@ -1362,7 +1362,7 @@ describe("handleLocalAgentStream", () => {
expect(contentUpdates.length).toBeGreaterThan(0); expect(contentUpdates.length).toBeGreaterThan(0);
const finalContent = contentUpdates[contentUpdates.length - 1].data const finalContent = contentUpdates[contentUpdates.length - 1].data
.content as string; .content as string;
expect(finalContent).toContain("First "); expect(finalContent).toContain("First");
expect(finalContent).not.toContain("Second"); expect(finalContent).not.toContain("Second");
}); });
......
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
Check, Check,
Info, Info,
Bot, Bot,
Ban,
} from "lucide-react"; } from "lucide-react";
import { formatDistanceToNow, format } from "date-fns"; import { formatDistanceToNow, format } from "date-fns";
import { useVersions } from "@/hooks/useVersions"; import { useVersions } from "@/hooks/useVersions";
...@@ -28,6 +29,10 @@ import { ...@@ -28,6 +29,10 @@ import {
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { unescapeXmlAttr } from "../../../shared/xmlEscape"; import { unescapeXmlAttr } from "../../../shared/xmlEscape";
import {
isCancelledResponseContent,
stripCancelledResponseNotice,
} from "@/shared/chatCancellation";
/** Extract <dyad-attachment> tags from message content and return parsed attachment data. */ /** Extract <dyad-attachment> tags from message content and return parsed attachment data. */
function extractAttachments(content: string): { function extractAttachments(content: string): {
...@@ -76,16 +81,29 @@ function stripAttachmentInfo(content: string): string { ...@@ -76,16 +81,29 @@ function stripAttachmentInfo(content: string): string {
interface ChatMessageProps { interface ChatMessageProps {
message: Message; message: Message;
isLastMessage: boolean; isLastMessage: boolean;
isCancelledPrompt?: boolean;
} }
const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { const ChatMessage = ({
message,
isLastMessage,
isCancelledPrompt,
}: ChatMessageProps) => {
const { isStreaming } = useStreamChat(); const { isStreaming } = useStreamChat();
const appId = useAtomValue(selectedAppIdAtom); const appId = useAtomValue(selectedAppIdAtom);
const { versions: liveVersions } = useVersions(appId); const { versions: liveVersions } = useVersions(appId);
const assistantTextContent =
message.role === "assistant"
? stripCancelledResponseNotice(message.content)
: "";
const hasAssistantText =
message.role === "assistant" && assistantTextContent.length > 0;
//handle copy chat //handle copy chat
const { copyMessageContent, copied } = useCopyToClipboard(); const { copyMessageContent, copied } = useCopyToClipboard();
const handleCopyFormatted = async () => { const handleCopyFormatted = async () => {
await copyMessageContent(message.content); await copyMessageContent(
message.role === "assistant" ? assistantTextContent : message.content,
);
}; };
// Find the version that was active when this message was sent // Find the version that was active when this message was sent
const messageVersion = useMemo(() => { const messageVersion = useMemo(() => {
...@@ -129,6 +147,8 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -129,6 +147,8 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
} }
}; };
const isCancelled =
isCancelledResponseContent(message.content) || !!isCancelledPrompt;
const userTextContent = const userTextContent =
message.role === "user" ? stripAttachmentInfo(message.content) : ""; message.role === "user" ? stripAttachmentInfo(message.content) : "";
const attachments = const attachments =
...@@ -141,7 +161,9 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -141,7 +161,9 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
<div <div
className={`flex ${message.role === "assistant" ? "justify-start" : "justify-end"}`} className={`flex ${message.role === "assistant" ? "justify-start" : "justify-end"}`}
> >
<div className={`mt-2 w-full max-w-3xl mx-auto group`}> <div
className={`mt-2 w-full max-w-3xl mx-auto group ${isCancelled ? "opacity-50" : ""}`}
>
{/* Show message box for assistant messages or user messages with text */} {/* Show message box for assistant messages or user messages with text */}
{(message.role === "assistant" || hasUserText) && ( {(message.role === "assistant" || hasUserText) && (
<div <div
...@@ -150,10 +172,16 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -150,10 +172,16 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
}`} }`}
> >
{message.role === "assistant" && {message.role === "assistant" &&
!message.content && !hasAssistantText &&
isStreaming && isStreaming &&
isLastMessage ? ( isLastMessage ? (
<StreamingLoadingAnimation variant="initial" /> <StreamingLoadingAnimation variant="initial" />
) : message.role === "assistant" &&
!hasAssistantText &&
isCancelled ? (
<div className="prose dark:prose-invert max-w-none text-[15px] italic text-muted-foreground">
Response cancelled before any content was generated.
</div>
) : ( ) : (
<div <div
className="prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none break-words text-[15px]" className="prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none break-words text-[15px]"
...@@ -161,7 +189,7 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -161,7 +189,7 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
> >
{message.role === "assistant" ? ( {message.role === "assistant" ? (
<> <>
<DyadMarkdownParser content={message.content} /> <DyadMarkdownParser content={assistantTextContent} />
{isLastMessage && isStreaming && ( {isLastMessage && isStreaming && (
<StreamingLoadingAnimation variant="streaming" /> <StreamingLoadingAnimation variant="streaming" />
)} )}
...@@ -171,45 +199,36 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -171,45 +199,36 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
)} )}
</div> </div>
)} )}
{(message.role === "assistant" && {(hasAssistantText && !isStreaming) || message.approvalState ? (
message.content &&
!isStreaming) ||
message.approvalState ? (
<div <div
className={`mt-2 flex items-center ${ className={`mt-2 flex items-center ${
message.role === "assistant" && hasAssistantText && !isStreaming ? "justify-between" : ""
message.content &&
!isStreaming
? "justify-between"
: ""
} text-xs`} } text-xs`}
> >
{message.role === "assistant" && {hasAssistantText && !isStreaming && (
message.content && <Tooltip>
!isStreaming && ( <TooltipTrigger
<Tooltip> render={
<TooltipTrigger <button
render={ data-testid="copy-message-button"
<button onClick={handleCopyFormatted}
data-testid="copy-message-button" aria-label="Copy"
onClick={handleCopyFormatted} className="flex items-center space-x-1 px-2 py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors duration-200 cursor-pointer"
aria-label="Copy" />
className="flex items-center space-x-1 px-2 py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors duration-200 cursor-pointer" }
/> >
} {copied ? (
> <Check className="h-4 w-4 text-green-500" />
{copied ? ( ) : (
<Check className="h-4 w-4 text-green-500" /> <Copy className="h-4 w-4" />
) : ( )}
<Copy className="h-4 w-4" /> <span className="hidden sm:inline"></span>
)} </TooltipTrigger>
<span className="hidden sm:inline"></span> <TooltipContent>
</TooltipTrigger> {copied ? "Copied!" : "Copy"}
<TooltipContent> </TooltipContent>
{copied ? "Copied!" : "Copy"} </Tooltip>
</TooltipContent> )}
</Tooltip>
)}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{message.approvalState && ( {message.approvalState && (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
...@@ -335,6 +354,12 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -335,6 +354,12 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
)} )}
</div> </div>
)} )}
{isCancelled && (
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-gray-500 dark:text-gray-400">
<Ban className="h-3 w-3" />
<span>Cancelled</span>
</div>
)}
</div> </div>
</div> </div>
); );
......
...@@ -20,6 +20,7 @@ import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders"; ...@@ -20,6 +20,7 @@ import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo"; import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import { PromoMessage } from "./PromoMessage"; import { PromoMessage } from "./PromoMessage";
import { isCancelledResponseContent } from "@/shared/chatCancellation";
interface MessagesListProps { interface MessagesListProps {
messages: Message[]; messages: Message[];
...@@ -300,6 +301,21 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>( ...@@ -300,6 +301,21 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
isAnyProviderSetup, isAnyProviderSetup,
]); ]);
// Precompute which indices are cancelled prompts so the callback
// can depend on this set instead of the full messages array reference.
const cancelledPromptIndices = useMemo(() => {
const indices = new Set<number>();
for (let i = 0; i < messages.length - 1; i++) {
if (
messages[i].role === "user" &&
isCancelledResponseContent(messages[i + 1].content)
) {
indices.add(i);
}
}
return indices;
}, [messages]);
// Memoized item renderer for virtualized list // Memoized item renderer for virtualized list
const itemContent = useCallback( const itemContent = useCallback(
(index: number, message: Message) => { (index: number, message: Message) => {
...@@ -311,11 +327,12 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>( ...@@ -311,11 +327,12 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
<MemoizedChatMessage <MemoizedChatMessage
message={message} message={message}
isLastMessage={isLastMessage} isLastMessage={isLastMessage}
isCancelledPrompt={cancelledPromptIndices.has(index)}
/> />
</div> </div>
); );
}, },
[messages.length], [messages.length, cancelledPromptIndices],
); );
// Create context object for Footer component with stable references // Create context object for Footer component with stable references
...@@ -400,7 +417,11 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>( ...@@ -400,7 +417,11 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
const isLastMessage = index === messages.length - 1; const isLastMessage = index === messages.length - 1;
return ( return (
<div className="px-4" key={message.id}> <div className="px-4" key={message.id}>
<ChatMessage message={message} isLastMessage={isLastMessage} /> <ChatMessage
message={message}
isLastMessage={isLastMessage}
isCancelledPrompt={cancelledPromptIndices.has(index)}
/>
</div> </div>
); );
})} })}
......
...@@ -33,6 +33,7 @@ import { useCheckProblems } from "./useCheckProblems"; ...@@ -33,6 +33,7 @@ import { useCheckProblems } from "./useCheckProblems";
import { useSettings } from "./useSettings"; import { useSettings } from "./useSettings";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { applyCancellationNoticeToLastAssistantMessage } from "@/shared/chatCancellation";
export function getRandomNumberId() { export function getRandomNumberId() {
return Math.floor(Math.random() * 1_000_000_000_000_000); return Math.floor(Math.random() * 1_000_000_000_000_000);
...@@ -218,6 +219,25 @@ export function useStreamChat({ ...@@ -218,6 +219,25 @@ export function useStreamChat({
pendingStreamChatIds.delete(chatId); pendingStreamChatIds.delete(chatId);
// Only mark as successful if NOT cancelled - wasCancelled flag is set // Only mark as successful if NOT cancelled - wasCancelled flag is set
// by the backend when user cancels the stream // by the backend when user cancels the stream
if (response.wasCancelled) {
setMessagesById((prev) => {
const existingMessages = prev.get(chatId);
if (!existingMessages) return prev;
const updatedMessages =
applyCancellationNoticeToLastAssistantMessage(
existingMessages,
);
if (updatedMessages === existingMessages) {
return prev;
}
const next = new Map(prev);
next.set(chatId, updatedMessages);
return next;
});
}
if (!response.wasCancelled) { if (!response.wasCancelled) {
setStreamCompletedSuccessfullyById((prev) => { setStreamCompletedSuccessfullyById((prev) => {
const next = new Map(prev); const next = new Map(prev);
......
...@@ -78,6 +78,10 @@ import { ...@@ -78,6 +78,10 @@ import {
getDyadRenameTags, getDyadRenameTags,
} from "../utils/dyad_tag_parser"; } from "../utils/dyad_tag_parser";
import { fileExists } from "../utils/file_utils"; import { fileExists } from "../utils/file_utils";
import {
appendCancelledResponseNotice,
filterCancelledMessagePairs,
} from "@/shared/chatCancellation";
import { extractMentionedAppsCodebases } from "../utils/mention_apps"; import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps"; import { parseAppMentions } from "@/shared/parse_mention_apps";
import { import {
...@@ -686,13 +690,17 @@ ${componentSnippet} ...@@ -686,13 +690,17 @@ ${componentSnippet}
); );
// Prepare message history for the AI // Prepare message history for the AI
const messageHistory = updatedChat.messages.map((message) => ({ const messageHistoryRaw = updatedChat.messages.map((message) => ({
role: message.role as "user" | "assistant" | "system", role: message.role as "user" | "assistant" | "system",
content: message.content, content: message.content,
sourceCommitHash: message.sourceCommitHash, sourceCommitHash: message.sourceCommitHash,
commitHash: message.commitHash, commitHash: message.commitHash,
})); }));
// Filter out cancelled message pairs (user prompt + cancelled assistant response)
// so the AI doesn't try to reconcile cancelled/incorrect prompts with new ones.
const messageHistory = filterCancelledMessagePairs(messageHistoryRaw);
// The DB stores display-friendly versions (short /implement-plan= form // The DB stores display-friendly versions (short /implement-plan= form
// or clean <dyad-attachment> tags). Replace the last user message with the // or clean <dyad-attachment> tags). Replace the last user message with the
// full AI prompt so the model receives expanded plan content or attachment paths. // full AI prompt so the model receives expanded plan content or attachment paths.
...@@ -1611,30 +1619,25 @@ ${problemReport.problems ...@@ -1611,30 +1619,25 @@ ${problemReport.problems
// Check if this was an abort error // Check if this was an abort error
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
const chatId = req.chatId; const chatId = req.chatId;
const partialResponse = partialResponses.get(req.chatId); const partialResponse = partialResponses.get(req.chatId) ?? "";
// If we have a partial response, save it to the database try {
if (partialResponse) { // Update the placeholder assistant message with the partial content and cancellation note
try { await db
// Update the placeholder assistant message with the partial content and cancellation note .update(messages)
await db .set({
.update(messages) content: appendCancelledResponseNotice(partialResponse),
.set({ })
content: `${partialResponse} .where(eq(messages.id, placeholderAssistantMessage.id));
[Response cancelled by user]`,
})
.where(eq(messages.id, placeholderAssistantMessage.id));
logger.log( logger.log(
`Updated cancelled response for placeholder message ${placeholderAssistantMessage.id} in chat ${chatId}`, `Updated cancelled response for placeholder message ${placeholderAssistantMessage.id} in chat ${chatId}`,
); );
partialResponses.delete(req.chatId); partialResponses.delete(req.chatId);
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error saving partial response for chat ${chatId}:`, `Error saving partial response for chat ${chatId}:`,
error, error,
); );
}
} }
return req.chatId; return req.chatId;
} }
...@@ -1642,6 +1645,26 @@ ${problemReport.problems ...@@ -1642,6 +1645,26 @@ ${problemReport.problems
} }
} }
// If the stream was aborted but didn't throw (e.g. stream ended gracefully),
// save the cancellation notice to the placeholder message.
if (abortController.signal.aborted) {
const partialResponse = partialResponses.get(req.chatId) ?? "";
try {
await db
.update(messages)
.set({
content: appendCancelledResponseNotice(partialResponse),
})
.where(eq(messages.id, placeholderAssistantMessage.id));
partialResponses.delete(req.chatId);
} catch (error) {
logger.error(
`Error saving cancelled response for chat ${req.chatId}:`,
error,
);
}
}
// Only save the response and process it if we weren't aborted // Only save the response and process it if we weren't aborted
if (!abortController.signal.aborted && fullResponse) { if (!abortController.signal.aborted && fullResponse) {
// Scrape from: <dyad-chat-summary>Renaming profile file</dyad-chat-title> // Scrape from: <dyad-chat-summary>Renaming profile file</dyad-chat-title>
......
...@@ -74,6 +74,10 @@ import { parseMcpToolKey, sanitizeMcpName } from "@/ipc/utils/mcp_tool_utils"; ...@@ -74,6 +74,10 @@ import { parseMcpToolKey, sanitizeMcpName } from "@/ipc/utils/mcp_tool_utils";
import { addIntegrationTool } from "./tools/add_integration"; import { addIntegrationTool } from "./tools/add_integration";
import { writePlanTool } from "./tools/write_plan"; import { writePlanTool } from "./tools/write_plan";
import { exitPlanTool } from "./tools/exit_plan"; import { exitPlanTool } from "./tools/exit_plan";
import {
appendCancelledResponseNotice,
filterCancelledMessagePairs,
} from "@/shared/chatCancellation";
import { import {
isChatPendingCompaction, isChatPendingCompaction,
performCompaction, performCompaction,
...@@ -205,10 +209,15 @@ function buildChatMessageHistory( ...@@ -205,10 +209,15 @@ function buildChatMessageHistory(
reorderedMessages.splice(targetIndex, 0, summary); reorderedMessages.splice(targetIndex, 0, summary);
} }
return reorderedMessages const filtered = reorderedMessages
.filter((msg) => !excludedIds?.has(msg.id)) .filter((msg) => !excludedIds?.has(msg.id))
.filter((msg) => msg.content || msg.aiMessagesJson) .filter((msg) => msg.content || msg.aiMessagesJson);
.flatMap((msg) => parseAiMessagesJson(msg));
// Filter out cancelled message pairs (user prompt + cancelled assistant response)
// so the AI doesn't try to reconcile cancelled/incorrect prompts with new ones.
return filterCancelledMessagePairs(filtered).flatMap((msg) =>
parseAiMessagesJson(msg),
);
} }
function getMidTurnCompactionSummaryIds( function getMidTurnCompactionSummaryIds(
...@@ -1176,12 +1185,12 @@ export async function handleLocalAgentStream( ...@@ -1176,12 +1185,12 @@ export async function handleLocalAgentStream(
// Handle cancellation paths where stream processing exits cleanly after abort. // Handle cancellation paths where stream processing exits cleanly after abort.
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
if (fullResponse) { await db
await db .update(messages)
.update(messages) .set({
.set({ content: `${fullResponse}\n\n[Response cancelled by user]` }) content: appendCancelledResponseNotice(fullResponse ?? ""),
.where(eq(messages.id, placeholderMessageId)); })
} .where(eq(messages.id, placeholderMessageId));
return false; // Cancelled - don't consume quota return false; // Cancelled - don't consume quota
} }
...@@ -1267,12 +1276,12 @@ export async function handleLocalAgentStream( ...@@ -1267,12 +1276,12 @@ export async function handleLocalAgentStream(
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
// Handle cancellation // Handle cancellation
if (fullResponse) { await db
await db .update(messages)
.update(messages) .set({
.set({ content: `${fullResponse}\n\n[Response cancelled by user]` }) content: appendCancelledResponseNotice(fullResponse ?? ""),
.where(eq(messages.id, placeholderMessageId)); })
} .where(eq(messages.id, placeholderMessageId));
return false; // Cancelled - don't consume quota return false; // Cancelled - don't consume quota
} }
......
const RESPONSE_CANCELLED_BY_USER_NOTICE = "[Response cancelled by user]";
export function isCancelledResponseContent(content: string): boolean {
return content.trimEnd().endsWith(RESPONSE_CANCELLED_BY_USER_NOTICE);
}
export function appendCancelledResponseNotice(content: string): string {
const trimmedContent = content.trimEnd();
if (isCancelledResponseContent(trimmedContent)) {
return trimmedContent;
}
return trimmedContent
? `${trimmedContent}\n\n${RESPONSE_CANCELLED_BY_USER_NOTICE}`
: RESPONSE_CANCELLED_BY_USER_NOTICE;
}
export function stripCancelledResponseNotice(content: string): string {
const trimmedContent = content.trimEnd();
if (!isCancelledResponseContent(trimmedContent)) {
return content;
}
return trimmedContent
.slice(0, -RESPONSE_CANCELLED_BY_USER_NOTICE.length)
.trimEnd();
}
/**
* Filters out cancelled message pairs (user prompt + cancelled assistant response)
* so the AI doesn't try to reconcile cancelled/incorrect prompts with new ones.
*/
export function filterCancelledMessagePairs<
T extends { role: string; content: string },
>(messages: T[]): T[] {
return messages.filter((msg, index) => {
if (isCancelledResponseContent(msg.content)) {
return false;
}
// Also filter the preceding user message that triggered the cancelled response
if (
msg.role === "user" &&
index + 1 < messages.length &&
isCancelledResponseContent(messages[index + 1].content)
) {
return false;
}
return true;
});
}
export function applyCancellationNoticeToLastAssistantMessage<
T extends { role: string; content: string },
>(messages: T[]): T[] {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role !== "assistant") {
continue;
}
const nextContent = appendCancelledResponseNotice(message.content);
if (nextContent === message.content) {
return messages;
}
const nextMessages = messages.slice();
nextMessages[index] = {
...message,
content: nextContent,
};
return nextMessages;
}
return messages;
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论