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",
"version": "0.40.0",
"version": "0.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dyad",
"version": "0.40.0",
"version": "0.41.0",
"license": "MIT",
"dependencies": {
"@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", () => {
expect(contentUpdates.length).toBeGreaterThan(0);
const finalContent = contentUpdates[contentUpdates.length - 1].data
.content as string;
expect(finalContent).toContain("First ");
expect(finalContent).toContain("First");
expect(finalContent).not.toContain("Second");
});
......
......@@ -15,6 +15,7 @@ import {
Check,
Info,
Bot,
Ban,
} from "lucide-react";
import { formatDistanceToNow, format } from "date-fns";
import { useVersions } from "@/hooks/useVersions";
......@@ -28,6 +29,10 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { unescapeXmlAttr } from "../../../shared/xmlEscape";
import {
isCancelledResponseContent,
stripCancelledResponseNotice,
} from "@/shared/chatCancellation";
/** Extract <dyad-attachment> tags from message content and return parsed attachment data. */
function extractAttachments(content: string): {
......@@ -76,16 +81,29 @@ function stripAttachmentInfo(content: string): string {
interface ChatMessageProps {
message: Message;
isLastMessage: boolean;
isCancelledPrompt?: boolean;
}
const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
const ChatMessage = ({
message,
isLastMessage,
isCancelledPrompt,
}: ChatMessageProps) => {
const { isStreaming } = useStreamChat();
const appId = useAtomValue(selectedAppIdAtom);
const { versions: liveVersions } = useVersions(appId);
const assistantTextContent =
message.role === "assistant"
? stripCancelledResponseNotice(message.content)
: "";
const hasAssistantText =
message.role === "assistant" && assistantTextContent.length > 0;
//handle copy chat
const { copyMessageContent, copied } = useCopyToClipboard();
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
const messageVersion = useMemo(() => {
......@@ -129,6 +147,8 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
}
};
const isCancelled =
isCancelledResponseContent(message.content) || !!isCancelledPrompt;
const userTextContent =
message.role === "user" ? stripAttachmentInfo(message.content) : "";
const attachments =
......@@ -141,7 +161,9 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
<div
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 */}
{(message.role === "assistant" || hasUserText) && (
<div
......@@ -150,10 +172,16 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
}`}
>
{message.role === "assistant" &&
!message.content &&
!hasAssistantText &&
isStreaming &&
isLastMessage ? (
<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
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) => {
>
{message.role === "assistant" ? (
<>
<DyadMarkdownParser content={message.content} />
<DyadMarkdownParser content={assistantTextContent} />
{isLastMessage && isStreaming && (
<StreamingLoadingAnimation variant="streaming" />
)}
......@@ -171,45 +199,36 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
)}
</div>
)}
{(message.role === "assistant" &&
message.content &&
!isStreaming) ||
message.approvalState ? (
{(hasAssistantText && !isStreaming) || message.approvalState ? (
<div
className={`mt-2 flex items-center ${
message.role === "assistant" &&
message.content &&
!isStreaming
? "justify-between"
: ""
hasAssistantText && !isStreaming ? "justify-between" : ""
} text-xs`}
>
{message.role === "assistant" &&
message.content &&
!isStreaming && (
<Tooltip>
<TooltipTrigger
render={
<button
data-testid="copy-message-button"
onClick={handleCopyFormatted}
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" />
) : (
<Copy className="h-4 w-4" />
)}
<span className="hidden sm:inline"></span>
</TooltipTrigger>
<TooltipContent>
{copied ? "Copied!" : "Copy"}
</TooltipContent>
</Tooltip>
)}
{hasAssistantText && !isStreaming && (
<Tooltip>
<TooltipTrigger
render={
<button
data-testid="copy-message-button"
onClick={handleCopyFormatted}
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" />
) : (
<Copy className="h-4 w-4" />
)}
<span className="hidden sm:inline"></span>
</TooltipTrigger>
<TooltipContent>
{copied ? "Copied!" : "Copy"}
</TooltipContent>
</Tooltip>
)}
<div className="flex flex-wrap gap-2">
{message.approvalState && (
<div className="flex items-center space-x-1">
......@@ -335,6 +354,12 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
)}
</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>
);
......
......@@ -20,6 +20,7 @@ import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
import { useSettings } from "@/hooks/useSettings";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import { PromoMessage } from "./PromoMessage";
import { isCancelledResponseContent } from "@/shared/chatCancellation";
interface MessagesListProps {
messages: Message[];
......@@ -300,6 +301,21 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
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
const itemContent = useCallback(
(index: number, message: Message) => {
......@@ -311,11 +327,12 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
<MemoizedChatMessage
message={message}
isLastMessage={isLastMessage}
isCancelledPrompt={cancelledPromptIndices.has(index)}
/>
</div>
);
},
[messages.length],
[messages.length, cancelledPromptIndices],
);
// Create context object for Footer component with stable references
......@@ -400,7 +417,11 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
const isLastMessage = index === messages.length - 1;
return (
<div className="px-4" key={message.id}>
<ChatMessage message={message} isLastMessage={isLastMessage} />
<ChatMessage
message={message}
isLastMessage={isLastMessage}
isCancelledPrompt={cancelledPromptIndices.has(index)}
/>
</div>
);
})}
......
......@@ -33,6 +33,7 @@ import { useCheckProblems } from "./useCheckProblems";
import { useSettings } from "./useSettings";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { applyCancellationNoticeToLastAssistantMessage } from "@/shared/chatCancellation";
export function getRandomNumberId() {
return Math.floor(Math.random() * 1_000_000_000_000_000);
......@@ -218,6 +219,25 @@ export function useStreamChat({
pendingStreamChatIds.delete(chatId);
// Only mark as successful if NOT cancelled - wasCancelled flag is set
// 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) {
setStreamCompletedSuccessfullyById((prev) => {
const next = new Map(prev);
......
......@@ -78,6 +78,10 @@ import {
getDyadRenameTags,
} from "../utils/dyad_tag_parser";
import { fileExists } from "../utils/file_utils";
import {
appendCancelledResponseNotice,
filterCancelledMessagePairs,
} from "@/shared/chatCancellation";
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps";
import {
......@@ -686,13 +690,17 @@ ${componentSnippet}
);
// 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",
content: message.content,
sourceCommitHash: message.sourceCommitHash,
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
// 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.
......@@ -1611,30 +1619,25 @@ ${problemReport.problems
// Check if this was an abort error
if (abortController.signal.aborted) {
const chatId = req.chatId;
const partialResponse = partialResponses.get(req.chatId);
// If we have a partial response, save it to the database
if (partialResponse) {
try {
// Update the placeholder assistant message with the partial content and cancellation note
await db
.update(messages)
.set({
content: `${partialResponse}
[Response cancelled by user]`,
})
.where(eq(messages.id, placeholderAssistantMessage.id));
const partialResponse = partialResponses.get(req.chatId) ?? "";
try {
// Update the placeholder assistant message with the partial content and cancellation note
await db
.update(messages)
.set({
content: appendCancelledResponseNotice(partialResponse),
})
.where(eq(messages.id, placeholderAssistantMessage.id));
logger.log(
`Updated cancelled response for placeholder message ${placeholderAssistantMessage.id} in chat ${chatId}`,
);
partialResponses.delete(req.chatId);
} catch (error) {
logger.error(
`Error saving partial response for chat ${chatId}:`,
error,
);
}
logger.log(
`Updated cancelled response for placeholder message ${placeholderAssistantMessage.id} in chat ${chatId}`,
);
partialResponses.delete(req.chatId);
} catch (error) {
logger.error(
`Error saving partial response for chat ${chatId}:`,
error,
);
}
return req.chatId;
}
......@@ -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
if (!abortController.signal.aborted && fullResponse) {
// Scrape from: <dyad-chat-summary>Renaming profile file</dyad-chat-title>
......
......@@ -74,6 +74,10 @@ import { parseMcpToolKey, sanitizeMcpName } from "@/ipc/utils/mcp_tool_utils";
import { addIntegrationTool } from "./tools/add_integration";
import { writePlanTool } from "./tools/write_plan";
import { exitPlanTool } from "./tools/exit_plan";
import {
appendCancelledResponseNotice,
filterCancelledMessagePairs,
} from "@/shared/chatCancellation";
import {
isChatPendingCompaction,
performCompaction,
......@@ -205,10 +209,15 @@ function buildChatMessageHistory(
reorderedMessages.splice(targetIndex, 0, summary);
}
return reorderedMessages
const filtered = reorderedMessages
.filter((msg) => !excludedIds?.has(msg.id))
.filter((msg) => msg.content || msg.aiMessagesJson)
.flatMap((msg) => parseAiMessagesJson(msg));
.filter((msg) => msg.content || msg.aiMessagesJson);
// 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(
......@@ -1176,12 +1185,12 @@ export async function handleLocalAgentStream(
// Handle cancellation paths where stream processing exits cleanly after abort.
if (abortController.signal.aborted) {
if (fullResponse) {
await db
.update(messages)
.set({ content: `${fullResponse}\n\n[Response cancelled by user]` })
.where(eq(messages.id, placeholderMessageId));
}
await db
.update(messages)
.set({
content: appendCancelledResponseNotice(fullResponse ?? ""),
})
.where(eq(messages.id, placeholderMessageId));
return false; // Cancelled - don't consume quota
}
......@@ -1267,12 +1276,12 @@ export async function handleLocalAgentStream(
if (abortController.signal.aborted) {
// Handle cancellation
if (fullResponse) {
await db
.update(messages)
.set({ content: `${fullResponse}\n\n[Response cancelled by user]` })
.where(eq(messages.id, placeholderMessageId));
}
await db
.update(messages)
.set({
content: appendCancelledResponseNotice(fullResponse ?? ""),
})
.where(eq(messages.id, placeholderMessageId));
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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论