Skip to content

Commit f03b956

Browse files
fix(miner-ui): preserve chat history and partial answers on stream errors (#7077) (#7158)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8a42ad3 commit f03b956

3 files changed

Lines changed: 82 additions & 6 deletions

File tree

Lines changed: 15 additions & 0 deletions
Loading

apps/loopover-miner-ui/src/chat-conversation.test.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,17 @@ describe("ChatConversation (#6518)", () => {
6464
expect(screen.getByText("Hello")).toBeTruthy();
6565
});
6666

67-
it("surfaces a backend failure through the message-list error state and re-enables the composer", async () => {
67+
it("surfaces a backend failure as an inline system note and re-enables the composer (#7077)", async () => {
6868
const streamChatImpl = async function* (_messages: ChatWireMessage[]): AsyncGenerator<string> {
6969
yield* []; // yields nothing, then fails — models a backend/stream error mid-request
7070
throw new Error("connection refused");
7171
};
7272
render(<ChatConversation streamChatImpl={streamChatImpl} />);
7373
ask("hi");
7474

75-
await waitFor(() => expect(screen.getByText(/Couldn't load the conversation/i)).toBeTruthy());
75+
await waitFor(() => expect(screen.getByText(/latest response failed to complete/i)).toBeTruthy());
76+
expect(screen.getByText("hi")).toBeTruthy();
77+
expect(screen.queryByText(/Couldn't load the conversation/i)).toBeNull();
7678
expect(sendButton().disabled).toBe(false);
7779
});
7880

@@ -95,4 +97,41 @@ describe("ChatConversation (#6518)", () => {
9597
await waitFor(() => expect(screen.getByText("Hello")).toBeTruthy());
9698
expect(screen.queryByRole("status", { name: /is typing/i })).toBeNull();
9799
});
100+
101+
it("REGRESSION (#7077): a second-turn failure leaves the first successful turn visible", async () => {
102+
let calls = 0;
103+
const streamChatImpl = async function* (_messages: ChatWireMessage[]): AsyncGenerator<string> {
104+
calls += 1;
105+
if (calls === 1) {
106+
yield "first answer";
107+
return;
108+
}
109+
throw new Error("connection refused");
110+
};
111+
render(<ChatConversation streamChatImpl={streamChatImpl} />);
112+
ask("first question");
113+
await waitFor(() => expect(screen.getByText("first answer")).toBeTruthy());
114+
115+
ask("second question");
116+
await waitFor(() => expect(screen.getByText(/latest response failed to complete/i)).toBeTruthy());
117+
expect(screen.getByText("first question")).toBeTruthy();
118+
expect(screen.getByText("first answer")).toBeTruthy();
119+
expect(screen.getByText("second question")).toBeTruthy();
120+
expect(screen.queryByText(/Couldn't load the conversation/i)).toBeNull();
121+
expect(sendButton().disabled).toBe(false);
122+
});
123+
124+
it("REGRESSION (#7077): partial streamed text is preserved after a mid-stream failure", async () => {
125+
const streamChatImpl = async function* (_messages: ChatWireMessage[]): AsyncGenerator<string> {
126+
yield "Hel";
127+
throw new Error("connection reset");
128+
};
129+
render(<ChatConversation streamChatImpl={streamChatImpl} />);
130+
ask("hi");
131+
132+
await waitFor(() => expect(sendButton().disabled).toBe(false));
133+
expect(screen.getByText("Hel")).toBeTruthy();
134+
expect(screen.getByText(/latest response failed to complete/i)).toBeTruthy();
135+
expect(screen.queryByText(/Couldn't load the conversation/i)).toBeNull();
136+
});
98137
});

apps/loopover-miner-ui/src/components/chat/conversation.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import { streamChat, type ChatWireMessage } from "@/lib/chat-stream";
1717
// separate, later, flag-gated issue.
1818

1919
const ASSISTANT_NAME = "LoopOver";
20+
/** Inline failure note appended after a failed turn — keeps history visible instead of StateBoundary wipe (#7077). */
21+
const TURN_FAILED_MESSAGE =
22+
"The latest response failed to complete. Any partial answer above is incomplete — you can try again.";
2023

2124
/** Injectable so tests can drive the stream deterministically; defaults to the real `POST /api/chat` bridge. */
2225
export type StreamChatFn = (messages: ChatWireMessage[]) => AsyncIterable<string>;
@@ -28,7 +31,6 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
2831
// #7078: true from submit until the first SSE text chunk — drives MessageList's TypingIndicator so the
2932
// pre-first-token round-trip isn't a silent gap. Cleared on first chunk, stream completion, or error.
3033
const [awaitingFirstChunk, setAwaitingFirstChunk] = useState(false);
31-
const [errored, setErrored] = useState(false);
3234
const idCounter = useRef(0);
3335
const nextId = () => `m${(idCounter.current += 1)}`;
3436

@@ -46,7 +48,6 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
4648
.map((message) => ({ role: message.role, content: message.content }));
4749

4850
setMessages((prev) => [...prev, userMessage]);
49-
setErrored(false);
5051
setAwaitingFirstChunk(true);
5152
setStreaming(true);
5253

@@ -80,7 +81,28 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
8081
},
8182
]);
8283
} catch {
83-
setErrored(true);
84+
// #7077: never gate MessageList on isError (that replaces the whole history via StateBoundary).
85+
// Commit any partial streamed text, then append an inline system failure note so prior turns stay.
86+
const failedAt = new Date().toISOString();
87+
setMessages((prev) => {
88+
const next = [...prev];
89+
if (answer.length > 0) {
90+
next.push({
91+
id: nextId(),
92+
role: "assistant",
93+
content: answer,
94+
timestamp: failedAt,
95+
authorName: ASSISTANT_NAME,
96+
});
97+
}
98+
next.push({
99+
id: nextId(),
100+
role: "system",
101+
content: TURN_FAILED_MESSAGE,
102+
timestamp: failedAt,
103+
});
104+
return next;
105+
});
84106
} finally {
85107
setAwaitingFirstChunk(false);
86108
setStreaming(false);
@@ -99,7 +121,7 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
99121
<div className="flex h-full flex-col gap-2 p-4">
100122
<p className="font-mono text-token-xs uppercase tracking-[0.2em] text-primary">Chat</p>
101123
<div className="min-h-0 flex-1 overflow-hidden">
102-
<MessageList messages={messages} isError={errored} composing={streaming && awaitingFirstChunk} />
124+
<MessageList messages={messages} composing={streaming && awaitingFirstChunk} />
103125
{streaming && activeSource ? (
104126
<div className="flex gap-3 px-3 pt-4" data-testid="chat-streaming-response">
105127
<Avatar className="size-8 shrink-0">

0 commit comments

Comments
 (0)