Skip to content

Commit 63fd2f0

Browse files
committed
fix(react-core): route turn-2 Enter to send during interrupt-resume instead of aborting the run
On consecutive interrupts, pressing Enter for the second turn (turn-2) while the resumed run from turn-1 was still in flight routed the keystroke to the STOP action, aborting the in-flight resume instead of sending the new message. The fix gates the Enter handler on `canSend` so a pending/running state no longer maps Enter to STOP, and `onSubmitInput` now awaits the in-flight run's completion before dispatching the queued message — the message is sent after the current run finishes rather than aborting it. Also hardens the queuing and attachment tests to cover the consecutive- interrupt path and the send-after-run-completes behavior.
1 parent fdba5dc commit 63fd2f0

4 files changed

Lines changed: 342 additions & 18 deletions

File tree

packages/react-core/src/v2/components/chat/CopilotChat.tsx

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,15 @@ export function CopilotChat({
191191
consumeAttachments,
192192
} = useAttachments({ config: attachmentsConfig });
193193

194+
// onSubmitInput awaits an in-flight run before sending the new message, so
195+
// it must re-check the "uploading" guard against FRESH state AFTER the await
196+
// — the closure-captured `selectedAttachments` is stale across the await
197+
// (an upload can start during the wait).
198+
const selectedAttachmentsRef = useRef(selectedAttachments);
199+
useEffect(() => {
200+
selectedAttachmentsRef.current = selectedAttachments;
201+
}, [selectedAttachments]);
202+
194203
// Check if transcription is enabled
195204
const isTranscriptionEnabled = copilotkit.audioFileTranscriptionEnabled;
196205

@@ -288,14 +297,77 @@ export function CopilotChat({
288297

289298
const onSubmitInput = useCallback(
290299
async (value: string) => {
291-
// Block if uploads in progress
292-
const hasUploading = selectedAttachments.some(
293-
(a) => a.status === "uploading",
294-
);
295-
if (hasUploading) {
300+
// Block if uploads in progress (fast fail against current state before
301+
// the value is committed — re-checked against live state after the
302+
// await below, since an upload can start during the wait).
303+
if (
304+
selectedAttachmentsRef.current.some((a) => a.status === "uploading")
305+
) {
306+
console.error(
307+
"[CopilotKit] Cannot send while attachments are uploading (pre-await guard)",
308+
);
309+
setTranscriptionError("Cannot send while attachments are uploading.");
310+
return;
311+
}
312+
313+
// Clear the input immediately so the composer reflects the accepted send
314+
// even though the actual dispatch may be deferred behind the in-flight
315+
// run. If the post-await guard later BLOCKS the send (e.g. an upload
316+
// starts during the await), the typed text is RESTORED to the composer
317+
// below so it is never silently lost.
318+
setInputValue("");
319+
320+
// If a run is already in flight, let it finish before sending the new
321+
// message instead of pre-empting it. `copilotkit.runAgent` would
322+
// otherwise call `agent.detachActiveRun()` and ABORT the in-flight run.
323+
// That abort is harmful when the in-flight run is an interrupt RESUME:
324+
// the resume re-enters and completes the paused agent graph, and
325+
// aborting it mid-flight leaves the graph paused — so this new message
326+
// lands as another resume of the SAME paused graph (re-interrupting
327+
// with no fresh payload) instead of starting a clean new turn. This is
328+
// the consecutive-interrupt regression: pick turn-1's slot (kicks off
329+
// the resume), then immediately send turn-2's message — without waiting,
330+
// turn-2 aborts the resume and the 2nd interrupt's card never mounts.
331+
// Awaiting the active run's completion serializes the two turns so the
332+
// resume finishes (graph completes) before the new message starts a
333+
// fresh run.
334+
if (
335+
agent.isRunning &&
336+
"activeRunCompletionPromise" in agent &&
337+
(agent as unknown as { activeRunCompletionPromise?: Promise<unknown> })
338+
.activeRunCompletionPromise
339+
) {
340+
try {
341+
await (
342+
agent as unknown as {
343+
activeRunCompletionPromise?: Promise<unknown>;
344+
}
345+
).activeRunCompletionPromise;
346+
} catch (error) {
347+
// The in-flight run rejected — proceed with the new send anyway,
348+
// but log so a chronically-failing in-flight run is observable.
349+
console.error(
350+
"CopilotChat: in-flight run rejected while queuing send",
351+
error,
352+
);
353+
}
354+
}
355+
356+
// Re-check the uploading guard against LIVE attachment state: an upload
357+
// can start (or stay in flight) during the await above, so a snapshot
358+
// taken before the await could consume an attachment with an incomplete
359+
// source. On block, RESTORE the typed text to the composer (it was
360+
// optimistically cleared on accept) so the user's input is not silently
361+
// lost, and surface a user-visible banner — console.error alone is
362+
// invisible to the user.
363+
if (
364+
selectedAttachmentsRef.current.some((a) => a.status === "uploading")
365+
) {
296366
console.error(
297-
"[CopilotKit] Cannot send while attachments are uploading",
367+
"[CopilotKit] Cannot send while attachments are uploading (post-await re-check)",
298368
);
369+
setTranscriptionError("Cannot send while attachments are uploading.");
370+
setInputValue(value);
299371
return;
300372
}
301373

@@ -329,8 +401,6 @@ export function CopilotChat({
329401
});
330402
}
331403

332-
// Clear input after submitting
333-
setInputValue("");
334404
try {
335405
await copilotkit.runAgent({ agent });
336406
} catch (error) {
@@ -339,7 +409,7 @@ export function CopilotChat({
339409
},
340410
// copilotkit is intentionally excluded — it is a stable ref that never changes.
341411
// eslint-disable-next-line react-hooks/exhaustive-deps
342-
[agent, selectedAttachments, consumeAttachments],
412+
[agent, consumeAttachments],
343413
);
344414

345415
const handleSelectSuggestion = useCallback(

packages/react-core/src/v2/components/chat/CopilotChatInput.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1+
import type { KeyboardEvent, ChangeEvent } from "react";
12
import React, {
23
useState,
34
useRef,
4-
KeyboardEvent,
5-
ChangeEvent,
65
useEffect,
76
useLayoutEffect,
87
forwardRef,
@@ -13,8 +12,8 @@ import React, {
1312
import { twMerge } from "tailwind-merge";
1413
import { Plus, Mic, ArrowUp, X, Check, Square, Loader2 } from "lucide-react";
1514

15+
import type { CopilotChatLabels } from "../../providers/CopilotChatConfigurationProvider";
1616
import {
17-
CopilotChatLabels,
1817
useCopilotChatConfiguration,
1918
CopilotChatDefaultLabels,
2019
} from "../../providers/CopilotChatConfigurationProvider";
@@ -36,7 +35,8 @@ import {
3635
} from "../../components/ui/dropdown-menu";
3736

3837
import { CopilotChatAudioRecorder } from "./CopilotChatAudioRecorder";
39-
import { renderSlot, WithSlots } from "../../lib/slots";
38+
import type { WithSlots } from "../../lib/slots";
39+
import { renderSlot } from "../../lib/slots";
4040
import { cn } from "../../lib/utils";
4141

4242
export type CopilotChatInputMode = "input" | "transcribe" | "processing";
@@ -456,7 +456,15 @@ export function CopilotChatInput({
456456

457457
if (e.key === "Enter" && !e.shiftKey) {
458458
e.preventDefault();
459-
if (isProcessing) {
459+
// When the composer holds sendable text, Enter ALWAYS sends — even
460+
// while a run is in flight. A non-empty composer is unambiguous intent
461+
// to send a new message, not to stop the agent. This is what unblocks
462+
// consecutive interrupt pills: after picking turn-1's slot the resume
463+
// run is still streaming (`isProcessing` true) when the user types and
464+
// Enters turn-2's prompt; routing that Enter to `onStop` aborted the
465+
// run and the next interrupt never surfaced. Stop stays reachable via
466+
// Enter only when the composer is empty (the genuine stop affordance).
467+
if (isProcessing && !canSend) {
460468
onStop?.();
461469
} else {
462470
send();
@@ -510,6 +518,10 @@ export function CopilotChatInput({
510518
const canStop = !!onStop;
511519

512520
const handleSendButtonClick = () => {
521+
// The send/stop button is an explicit control: when a run is in flight it
522+
// renders as a Stop (Square) button, so a click maps to stop regardless of
523+
// composer contents. The Enter key behaves differently (see handleKeyDown):
524+
// a non-empty composer + Enter sends a new message rather than stopping.
513525
if (isProcessing) {
514526
onStop?.();
515527
return;

packages/react-core/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import React from "react";
22
import { describe, it, expect, vi } from "vitest";
3-
import { screen, fireEvent, waitFor } from "@testing-library/react";
3+
import { screen, fireEvent, waitFor, act } from "@testing-library/react";
44
import {
55
MockStepwiseAgent,
66
renderWithCopilotKit,
7+
runStartedEvent,
78
} from "../../../__tests__/utils/test-helpers";
89
import { CopilotChat } from "../CopilotChat";
910
import type { AttachmentUploadError } from "@copilotkit/shared";
10-
import { type BaseEvent, type RunAgentInput } from "@ag-ui/client";
11-
import { Observable, EMPTY } from "rxjs";
11+
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
12+
import type { Observable } from "rxjs";
13+
import { EMPTY } from "rxjs";
1214

1315
class NoopAgent extends MockStepwiseAgent {
1416
run(_input: RunAgentInput): Observable<BaseEvent> {
@@ -165,4 +167,122 @@ describe("CopilotChat attachments", () => {
165167
expect(onUploadFailed).not.toHaveBeenCalled();
166168
});
167169
});
170+
171+
describe("uploading guard across the in-flight-run await", () => {
172+
it("blocks the send and restores the typed text when an upload starts during the await window", async () => {
173+
// onSubmitInput checks the uploading guard, then (if a run is in flight)
174+
// awaits the run's completion before consuming attachments. If an upload
175+
// starts DURING that await, a guard taken only before the await would
176+
// miss it and consume/drop the in-progress attachment. The guard must be
177+
// re-checked against LIVE state after the await — blocking the send while
178+
// any attachment is still uploading, and RESTORING the optimistically
179+
// cleared composer text so the user's input is never silently lost.
180+
const agent = new MockStepwiseAgent();
181+
182+
// Open the await window: a run is in flight with a settleable
183+
// completion promise. (Attached after the run is in flight below so the
184+
// connect/run setup does not overwrite it.)
185+
let resolveInFlight: () => void = () => {};
186+
const inFlight = new Promise<void>((resolve) => {
187+
resolveInFlight = resolve;
188+
});
189+
190+
// Upload that we keep pending so the attachment stays "uploading".
191+
let resolveUpload: (v: {
192+
type: "url";
193+
value: string;
194+
mimeType?: string;
195+
}) => void = () => {};
196+
const onUpload = () =>
197+
new Promise<{ type: "url"; value: string; mimeType?: string }>(
198+
(resolve) => {
199+
resolveUpload = resolve;
200+
},
201+
);
202+
203+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
204+
const addMessageSpy = vi.spyOn(agent, "addMessage");
205+
206+
try {
207+
const { container } = renderWithCopilotKit({
208+
agent,
209+
children: (
210+
<CopilotChat
211+
welcomeScreen={false}
212+
attachments={{ enabled: true, onUpload }}
213+
/>
214+
),
215+
});
216+
217+
const input = await screen.findByRole("textbox");
218+
219+
// Run goes in flight (so the send will await its completion).
220+
agent.emit(runStartedEvent());
221+
await waitFor(() => {
222+
expect(agent.isRunning).toBe(true);
223+
});
224+
(
225+
agent as unknown as { activeRunCompletionPromise?: Promise<void> }
226+
).activeRunCompletionPromise = inFlight;
227+
228+
// No attachments yet — the pre-await guard passes. Fire the send; it
229+
// parks on the in-flight run's completion promise.
230+
fireEvent.change(input, {
231+
target: { value: "Send with no files yet" },
232+
});
233+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
234+
235+
// DURING the await, the user drops a file that begins uploading.
236+
const dropTarget = container.querySelector(
237+
'[data-testid="copilot-chat"]',
238+
);
239+
if (!dropTarget) throw new Error("no drop target");
240+
const file = createFile("late.png", 50, "image/png");
241+
await act(async () => {
242+
fireEvent.drop(dropTarget, {
243+
dataTransfer: { files: [file], types: ["Files"] },
244+
});
245+
});
246+
247+
// The attachment is now "uploading" (onUpload still pending).
248+
await waitFor(() => {
249+
expect(
250+
container.querySelector('[data-testid="copilot-chat"]'),
251+
).toBeTruthy();
252+
});
253+
254+
// Release the in-flight run so the queued send resumes past the await.
255+
await act(async () => {
256+
resolveInFlight();
257+
await Promise.resolve();
258+
});
259+
260+
// The re-checked guard blocks the send: the POST-AWAIT re-check fires
261+
// (distinct log message proves it ran after the await, not the
262+
// pre-await fast-fail) and no message is dispatched while the
263+
// attachment is still uploading.
264+
await waitFor(() => {
265+
expect(errorSpy).toHaveBeenCalledWith(
266+
"[CopilotKit] Cannot send while attachments are uploading (post-await re-check)",
267+
);
268+
});
269+
expect(addMessageSpy).not.toHaveBeenCalled();
270+
271+
// The typed text is PRESERVED in the composer (restored on the blocked
272+
// path) so the user's input is never silently lost.
273+
await waitFor(() => {
274+
const composer = screen.getByRole("textbox") as HTMLTextAreaElement;
275+
expect(composer.value).toBe("Send with no files yet");
276+
});
277+
278+
// Cleanup: let the pending upload resolve.
279+
await act(async () => {
280+
resolveUpload({ type: "url", value: "https://example.com/late.png" });
281+
await Promise.resolve();
282+
});
283+
} finally {
284+
errorSpy.mockRestore();
285+
}
286+
});
287+
});
168288
});

0 commit comments

Comments
 (0)