Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/features/workspaces/ai/ai-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,23 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) {
thread,
tools: activeTools,
});
// A regeneration must not see the answer it replaces: Think assembles
// the prompt from the stored path, whose stale assistant tail would
// turn the turn into a "continue" (cloudflare/agents#2028 — remove
// once #2038 ships). Continuations keep their in-flight assistant.
let turnMessages = ctx.messages;
if (!ctx.continuation && ctx.body?.regenerate === true) {
let end = turnMessages.length;
while (end > 0 && turnMessages[end - 1]?.role !== "user") {
end -= 1;
}
if (end > 0) {
turnMessages = turnMessages.slice(0, end);
}
}
const messages = await resolveChatAttachmentModelMessages({
bucket: this.env.WORKSPACE_FILES,
messages: ctx.messages,
messages: turnMessages,
threadId: thread.id,
userId: thread.userId,
workspaceId: thread.workspaceId,
Expand Down
4 changes: 3 additions & 1 deletion src/features/workspaces/components/AiChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function AiChatPanelLayout({ context }: AiChatPanelProps) {
const [activeThreadIsRecovering, setActiveThreadIsRecovering] = useState(false);
const {
activeThreadId,
threadViewKey,
isCreatingThread,
isLoading,
isMaximized,
Expand Down Expand Up @@ -64,12 +65,13 @@ function AiChatPanelLayout({ context }: AiChatPanelProps) {
threads={threads}
/>

<Suspense key={activeThreadId} fallback={<AiChatPanelLoading />}>
<Suspense key={threadViewKey} fallback={<AiChatPanelLoading />}>
<AiChatThreadView
context={context}
modelId={modelId}
onModelChange={onModelChange}
onRecoveringChange={setActiveThreadIsRecovering}
onStartNewChat={onNewChat}
threadSummary={threads.find((thread) => thread.id === activeThreadId)}
threadId={activeThreadId}
/>
Expand Down
86 changes: 74 additions & 12 deletions src/features/workspaces/components/ai-chat/AiChatMessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ChatErrorClassification, ChatErrorContext } from "@cloudflare/think";
import { AlertCircle, RotateCcw } from "lucide-react";
import { AlertCircle, Plus, RefreshCw, RotateCcw } from "lucide-react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import type { HTMLMotionProps } from "motion/react";
import type { ReactNode } from "react";
Expand Down Expand Up @@ -65,8 +65,12 @@ export type AiChatAssistantErrorState =
| {
classification?: ChatErrorClassification | null;
kind: "assistant";
message?: string | null;
stage?: ChatErrorContext["stage"] | null;
}
| {
kind: "aborted";
}
| {
kind: "connection";
};
Expand All @@ -93,6 +97,7 @@ interface AiChatMessageListProps {
assistantError?: AiChatAssistantErrorState | null;
messages: AiChatMessage[];
onRegenerateLastResponse?: () => void;
onStartNewChat?: () => void;
presentation: AiChatPresentation;
sentMessageAnimationId?: string | null;
workspaceId: string;
Expand All @@ -102,6 +107,7 @@ export default function AiChatMessageList({
assistantError,
messages,
onRegenerateLastResponse,
onStartNewChat,
presentation,
sentMessageAnimationId,
workspaceId,
Expand Down Expand Up @@ -164,6 +170,7 @@ export default function AiChatMessageList({
row={row}
status={status}
onRegenerateLastResponse={onRegenerateLastResponse}
onStartNewChat={onStartNewChat}
/>
</AiChatMessageScrollerItem>
))}
Expand Down Expand Up @@ -233,13 +240,15 @@ function AiChatListRowView({
hasAssistantContent,
lastAssistantMessageId,
onRegenerateLastResponse,
onStartNewChat,
row,
status,
}: {
canRetry: boolean;
hasAssistantContent: boolean;
lastAssistantMessageId: string | undefined;
onRegenerateLastResponse?: () => void;
onStartNewChat?: () => void;
row: AiChatListRow;
status: AiChatPresentation["status"];
}) {
Expand All @@ -259,6 +268,7 @@ function AiChatListRowView({
errorState={row.errorState}
hasAssistantContent={hasAssistantContent}
onRetry={onRegenerateLastResponse}
onStartNewChat={onStartNewChat}
/>
</AiChatTranscriptRail>
);
Expand All @@ -284,12 +294,25 @@ function AiChatAssistantError({
errorState,
hasAssistantContent,
onRetry,
onStartNewChat,
}: {
canRetry: boolean;
errorState: AiChatAssistantErrorState;
hasAssistantContent: boolean;
onRetry?: () => void;
onStartNewChat?: () => void;
}) {
// An overflowed chat can rarely be retried into success, so the escape
// hatch the copy suggests gets its own action.
const canStartNewChat =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

This behavior-change diff introduces several new error-rendering branches in the component (aborted notice, error-detail line, Refresh page button, Start new chat button), but none are exercised by tests. The existing ai-chat-error-state.test.ts validates the derivation of the error state objects, not the UI rendering in this file. Add component-level tests that assert the correct buttons and messages render for each errorState.kind and classification combination.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/workspaces/components/ai-chat/AiChatMessageList.tsx, line 307:

<comment>This behavior-change diff introduces several new error-rendering branches in the component (aborted notice, error-detail line, Refresh page button, Start new chat button), but none are exercised by tests. The existing `ai-chat-error-state.test.ts` validates the derivation of the error state objects, not the UI rendering in this file. Add component-level tests that assert the correct buttons and messages render for each `errorState.kind` and `classification` combination.</comment>

<file context>
@@ -284,12 +294,25 @@ function AiChatAssistantError({
 }) {
+	// An overflowed chat can rarely be retried into success, so the escape
+	// hatch the copy suggests gets its own action.
+	const canStartNewChat =
+		errorState.kind === "assistant" &&
+		errorState.classification === "context_overflow" &&
</file context>

errorState.kind === "assistant" &&
errorState.classification === "context_overflow" &&
Boolean(onStartNewChat);
// Classified errors get curated copy; for the rest the stored server
// message (usage limits, recovery reasons) beats an unexplained failure.
const errorDetail =
errorState.kind === "assistant" && !errorState.classification ? errorState.message : null;

return (
<Message>
<MessageContent>
Expand All @@ -300,25 +323,60 @@ function AiChatAssistantError({
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<p className="text-sm">
{getChatErrorMessage({
errorState,
hasAssistantContent,
})}
</p>
<div className="flex flex-col gap-1">
<p className="text-sm">
{getChatErrorMessage({
errorState,
hasAssistantContent,
})}
</p>
{errorDetail ? (
<p className="text-muted-foreground text-xs">{errorDetail}</p>
) : null}
</div>
</div>
{canRetry ? (
{errorState.kind === "connection" ? (
<Button
type="button"
variant="outline"
size="xs"
className="gap-1.5"
onClick={onRetry}
onClick={() => {
window.location.reload();
}}
>
<RotateCcw className="size-3" />
Try again
<RefreshCw className="size-3" />
Refresh page
</Button>
) : null}
{canRetry || canStartNewChat ? (
<div className="flex items-center gap-2">
{canRetry ? (
<Button
type="button"
variant="outline"
size="xs"
className="gap-1.5"
onClick={onRetry}
>
<RotateCcw className="size-3" />
Try again
</Button>
) : null}
{canStartNewChat ? (
<Button
type="button"
variant="outline"
size="xs"
className="gap-1.5"
onClick={onStartNewChat}
>
<Plus className="size-3" />
Start new chat
</Button>
) : null}
</div>
) : null}
</BubbleContent>
</Bubble>
</MessageContent>
Expand Down Expand Up @@ -434,7 +492,11 @@ function getChatErrorMessage({
hasAssistantContent: boolean;
}) {
if (errorState.kind === "connection") {
return "The chat connection closed before the response could finish. Refresh the page to reconnect.";
return "The chat connection closed before the response could finish.";
}

if (errorState.kind === "aborted") {
return "The response was stopped before it started.";
}

if (errorState.classification === "context_overflow") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ export default function AiChatThreadView({
modelId,
onModelChange,
onRecoveringChange,
onStartNewChat,
threadSummary,
threadId,
}: {
context: WorkspaceAiContextScope;
modelId: AiChatModelId;
onModelChange: (modelId: AiChatModelId) => void;
onRecoveringChange?: (isRecovering: boolean) => void;
onStartNewChat?: () => void;
threadSummary?: AIThreadSummary;
threadId: string;
}) {
Expand Down Expand Up @@ -69,6 +71,7 @@ export default function AiChatThreadView({
const assistantError = deriveAiChatAssistantErrorState({
chatStatus: presentation.status,
hasConnectionError: Boolean(connectionError),
lastMessageRole: messages.at(-1)?.role,
threadSummary,
});
const sendMessage = (message: PromptInputMessage, clearDraft = true) => {
Expand Down Expand Up @@ -124,6 +127,7 @@ export default function AiChatThreadView({
sentMessageAnimationId={sentMessageAnimationId}
workspaceId={context.workspaceId}
onRegenerateLastResponse={regenerate}
onStartNewChat={onStartNewChat}
/>

<div className="px-3 pb-3">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { isToolUIPart } from "ai";

import { WORKSPACE_REFERENCES_DATA_PART_TYPE } from "#/features/workspaces/ai/workspace-references";
import type {
AiChatMessage,
AiChatMessagePart,
Expand Down Expand Up @@ -203,10 +202,6 @@ export function getDisplayableParts(message: AiChatMessage): AiChatRenderablePar
}

export function isDisplayableMessagePart(part: AiChatMessagePart): boolean {
if (part.type === WORKSPACE_REFERENCES_DATA_PART_TYPE) {
return false;
}

if (part.type === "text") {
return part.text.length > 0 || part.state === "streaming";
}
Expand All @@ -219,16 +214,10 @@ export function isDisplayableMessagePart(part: AiChatMessagePart): boolean {
return isVisibleToolPart(part);
}

if (
part.type === "file" ||
part.type === "source-url" ||
part.type === "source-document" ||
part.type.startsWith("data-")
) {
return true;
}

return false;
// Deliberately excludes `data-*` parts: old transcripts can carry data parts
// from retired features, and counting one as displayable makes a message
// render as a blank bubble instead of falling through to "no response".
return part.type === "file" || part.type === "source-url" || part.type === "source-document";
}

export function getToolActivityForPart(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,51 @@ describe("AI chat error state", () => {
hasConnectionError: false,
threadSummary: {
lastErrorClassification: "context_overflow",
lastErrorMessage: "Context window exceeded",
lastErrorStage: "recovery",
lastRunResult: "error",
},
}),
).toEqual({
classification: "context_overflow",
kind: "assistant",
message: "Context window exceeded",
stage: "recovery",
});
});

it("flags a run stopped before the first token so the send visibly ended", () => {
expect(
deriveAiChatAssistantErrorState({
chatStatus: "ready",
hasConnectionError: false,
lastMessageRole: "user",
threadSummary: {
lastErrorClassification: null,
lastErrorMessage: null,
lastErrorStage: null,
lastRunResult: "aborted",
},
}),
).toEqual({ kind: "aborted" });
});

it("stays quiet for a mid-stream stop that kept its partial reply", () => {
expect(
deriveAiChatAssistantErrorState({
chatStatus: "ready",
hasConnectionError: false,
lastMessageRole: "assistant",
threadSummary: {
lastErrorClassification: null,
lastErrorMessage: null,
lastErrorStage: null,
lastRunResult: "aborted",
},
}),
).toBeNull();
});

it("keeps a terminal connection error distinct", () => {
expect(
deriveAiChatAssistantErrorState({
Expand Down
15 changes: 13 additions & 2 deletions src/features/workspaces/components/ai-chat/ai-chat-error-state.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type { AIThreadSummary } from "#/features/workspaces/ai/user-ai-agents";
import type { AiChatAssistantErrorState } from "#/features/workspaces/components/ai-chat/AiChatMessageList";
import type { AiChatStatus } from "#/features/workspaces/components/ai-chat/types";
import type { AiChatMessage, AiChatStatus } from "#/features/workspaces/components/ai-chat/types";

type AIThreadErrorSummary = Pick<
AIThreadSummary,
"lastErrorClassification" | "lastErrorStage" | "lastRunResult"
"lastErrorClassification" | "lastErrorMessage" | "lastErrorStage" | "lastRunResult"
>;

export function deriveAiChatAssistantErrorState(input: {
chatStatus: AiChatStatus;
hasConnectionError: boolean;
lastMessageRole?: AiChatMessage["role"];
threadSummary?: AIThreadErrorSummary;
}): AiChatAssistantErrorState | null {
if (input.hasConnectionError) {
Expand All @@ -30,12 +31,22 @@ export function deriveAiChatAssistantErrorState(input: {
...(threadError
? {
classification: threadError.lastErrorClassification,
message: threadError.lastErrorMessage,
stage: threadError.lastErrorStage,
}
: {}),
kind: "assistant",
};
}

// A run stopped before the first token leaves the user message as the tail
// with nothing after it — without this row there is no sign the send ended.
// A mid-stream stop keeps its partial assistant tail, so no row is needed.
if (input.threadSummary?.lastRunResult === "aborted" && input.lastMessageRole === "user") {
return {
kind: "aborted",
};
}

return null;
}
Loading