Skip to content

Commit 32551f2

Browse files
committed
fix(react-core): type the active-run completion contract and serialize the suggestion send path
Replace the brittle `"activeRunCompletionPromise" in agent` probe + double `as unknown as` casts in CopilotChat with a typed `RunCompletionAware` contract plus an `isRunCompletionAware` type guard, both exported from core. IntelligenceAgent now declares the property and implements the interface, so the in-flight-run await is reachable without a cast and non-Intelligence agents still degrade safely. Cast sites in the attachments/e2e tests and the MockStepwiseAgent helper are updated to the typed accessor. Factor the await-then-send logic into a shared `waitForActiveRunToSettle` helper and call it from BOTH onSubmitInput and handleSelectSuggestion. This closes the suggestion-path in-flight gap: selecting a suggestion mid-run no longer pre-empts/aborts the active run (e.g. an interrupt RESUME) — the same regression PR CopilotKit#5195 fixed for the typed-Enter path. Adds a red-green regression test that parks the suggestion send on the in-flight promise.
1 parent 33dd22e commit 32551f2

5 files changed

Lines changed: 244 additions & 62 deletions

File tree

packages/core/src/intelligence-agent.ts

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import {
2-
AbstractAgent,
1+
import type {
32
RunAgentInput,
43
RunAgentParameters,
54
RunAgentResult,
65
AgentSubscriber,
7-
EventType,
86
BaseEvent,
7+
} from "@ag-ui/client";
8+
import {
9+
AbstractAgent,
10+
EventType,
911
randomUUID,
1012
transformChunks,
1113
structuredClone_,
@@ -15,15 +17,14 @@ import {
1517
EMPTY,
1618
Subject,
1719
Notification,
18-
Observable,
1920
defer,
2021
dematerialize,
2122
lastValueFrom,
2223
merge,
2324
switchMap,
2425
throwError,
2526
} from "rxjs";
26-
import type { ObservableNotification } from "rxjs";
27+
import type { ObservableNotification, Observable } from "rxjs";
2728
import {
2829
catchError,
2930
endWith,
@@ -42,13 +43,15 @@ import { phoenixExponentialBackoff } from "@copilotkit/shared";
4243
import {
4344
ɵphoenixChannel$,
4445
ɵphoenixSocket$,
45-
type ɵPhoenixChannelSession,
46-
type ɵPhoenixSocketSession,
4746
ɵjoinPhoenixChannel$,
4847
ɵobservePhoenixSocketSignals$,
4948
ɵobservePhoenixSocketHealth$,
5049
ɵobservePhoenixEvent$,
5150
} from "./utils/phoenix-observable";
51+
import type {
52+
ɵPhoenixChannelSession,
53+
ɵPhoenixSocketSession,
54+
} from "./utils/phoenix-observable";
5255

5356
const CLIENT_AG_UI_EVENT = "ag_ui_event";
5457
const REPLAY_COMPLETE_EVENT = "replay_complete";
@@ -78,6 +81,45 @@ export class AgentThreadLockedError extends Error {
7881
}
7982
}
8083

84+
/**
85+
* Typed contract for agents that expose the completion promise of their
86+
* currently in-flight run.
87+
*
88+
* `IntelligenceAgent` resolves this promise once a run's observable pipeline
89+
* finalizes (see {@link IntelligenceAgent.connectAgent}). Consumers (e.g. the
90+
* v2 `CopilotChat` send-serialization path) await it to let an in-flight run —
91+
* notably an interrupt RESUME — finish before dispatching a new run, instead
92+
* of pre-empting it.
93+
*
94+
* The base `AbstractAgent` from `@ag-ui/client` does NOT declare this property,
95+
* so it is reachable only through this contract plus the
96+
* {@link isRunCompletionAware} type guard. This keeps callers off `as unknown`
97+
* casts while still degrading safely for agents that don't implement it.
98+
*/
99+
export interface RunCompletionAware {
100+
/**
101+
* Resolves when the active run's pipeline finalizes (completes, errors, or is
102+
* detached). `undefined` when no run is in flight.
103+
*/
104+
readonly activeRunCompletionPromise?: Promise<void>;
105+
}
106+
107+
/**
108+
* Type guard for {@link RunCompletionAware}. Returns true when `agent` exposes
109+
* an `activeRunCompletionPromise` property, so callers can await an in-flight
110+
* run without an `as unknown as` cast. Returns false for agents that don't
111+
* implement the contract, letting the caller skip the await and degrade safely.
112+
*/
113+
export function isRunCompletionAware(
114+
agent: unknown,
115+
): agent is RunCompletionAware {
116+
return (
117+
typeof agent === "object" &&
118+
agent !== null &&
119+
"activeRunCompletionPromise" in agent
120+
);
121+
}
122+
81123
export interface IntelligenceAgentConfig {
82124
/** Phoenix websocket URL, e.g. "ws://localhost:4000/socket" */
83125
url: string;
@@ -93,12 +135,21 @@ export interface IntelligenceAgentConfig {
93135
credentials?: RequestCredentials;
94136
}
95137

96-
export class IntelligenceAgent extends AbstractAgent {
138+
export class IntelligenceAgent
139+
extends AbstractAgent
140+
implements RunCompletionAware
141+
{
97142
private config: IntelligenceAgentConfig;
98143
private socket: Socket | null = null;
99144
private activeChannel: Channel | null = null;
100145
private canonicalRunId: string | null = null;
101146
private sharedState: IntelligenceAgentSharedState;
147+
/**
148+
* Resolves when the active run's pipeline finalizes; `undefined` between runs.
149+
* Set/cleared inside {@link connectAgent}'s observable lifecycle. Declared
150+
* here so {@link RunCompletionAware} consumers can read it without a cast.
151+
*/
152+
activeRunCompletionPromise?: Promise<void>;
102153

103154
constructor(
104155
config: IntelligenceAgentConfig,
@@ -177,7 +228,7 @@ export class IntelligenceAgent extends AbstractAgent {
177228

178229
self.activeRunDetach$ = new Subject<void>();
179230
let resolveCompletion: (() => void) | undefined;
180-
self.activeRunCompletionPromise = new Promise<void>((resolve) => {
231+
this.activeRunCompletionPromise = new Promise<void>((resolve) => {
181232
resolveCompletion = resolve;
182233
});
183234

@@ -202,7 +253,7 @@ export class IntelligenceAgent extends AbstractAgent {
202253
this.onFinalize(input, subscribers);
203254
resolveCompletion?.();
204255
resolveCompletion = undefined;
205-
self.activeRunCompletionPromise = undefined;
256+
this.activeRunCompletionPromise = undefined;
206257
self.activeRunDetach$ = undefined;
207258
}),
208259
),

packages/react-core/src/v2/__tests__/utils/test-helpers.tsx

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,39 @@ import { CopilotKitProvider } from "../../providers/CopilotKitProvider";
44
import { CopilotChat } from "../../components/chat/CopilotChat";
55
import { CopilotChatConfigurationProvider } from "../../providers/CopilotChatConfigurationProvider";
66
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
7-
import {
8-
AbstractAgent,
9-
EventType,
10-
type BaseEvent,
11-
type RunAgentInput,
12-
} from "@ag-ui/client";
13-
import { Observable, Subject, from, delay } from "rxjs";
14-
import {
7+
import { AbstractAgent, EventType } from "@ag-ui/client";
8+
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
9+
import type { Observable } from "rxjs";
10+
import { Subject, from, delay } from "rxjs";
11+
import type { RunCompletionAware } from "@copilotkit/core";
12+
import type {
1513
ReactActivityMessageRenderer,
1614
ReactToolCallRenderer,
1715
} from "../../types";
18-
import { ReactCustomMessageRenderer } from "../../types/react-custom-message-renderer";
16+
import type { ReactCustomMessageRenderer } from "../../types/react-custom-message-renderer";
1917

2018
/**
2119
* A controllable mock agent for deterministic E2E testing.
2220
* Exposes emit() and complete() methods to drive agent events step-by-step.
21+
*
22+
* Implements {@link RunCompletionAware} so tests can open the send-serialization
23+
* await window (`onSubmitInput`/`handleSelectSuggestion` await this before
24+
* dispatching) by assigning a controllable completion promise — without an
25+
* `as unknown as` cast.
2326
*/
24-
export class MockStepwiseAgent extends AbstractAgent {
27+
export class MockStepwiseAgent
28+
extends AbstractAgent
29+
implements RunCompletionAware
30+
{
2531
private subject = new Subject<BaseEvent>();
2632

33+
/**
34+
* Resolves when the active run's pipeline finalizes; `undefined` between runs.
35+
* Tests set this to a controllable promise to exercise the await-then-send
36+
* serialization path. Mirrors the real `IntelligenceAgent` contract.
37+
*/
38+
activeRunCompletionPromise?: Promise<void>;
39+
2740
/**
2841
* Emit a single agent event
2942
*/

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

Lines changed: 48 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "@copilotkit/shared";
1717
import type { AttachmentsConfig, InputContent } from "@copilotkit/shared";
1818
import type { Suggestion, CopilotKitCoreErrorCode } from "@copilotkit/core";
19+
import { isRunCompletionAware } from "@copilotkit/core";
1920
import React, {
2021
useCallback,
2122
useEffect,
@@ -295,6 +296,43 @@ export function CopilotChat({
295296
// eslint-disable-next-line react-hooks/exhaustive-deps
296297
}, [resolvedThreadId, agent, resolvedAgentId, hasExplicitThreadId]);
297298

299+
// Serializes consecutive sends: if a run is already in flight, let it finish
300+
// before dispatching the next message instead of pre-empting it.
301+
// `copilotkit.runAgent` would otherwise call `agent.detachActiveRun()` and
302+
// ABORT the in-flight run. That abort is harmful when the in-flight run is an
303+
// interrupt RESUME: the resume re-enters and completes the paused agent
304+
// graph, and aborting it mid-flight leaves the graph paused — so the new
305+
// message lands as another resume of the SAME paused graph (re-interrupting
306+
// with no fresh payload) instead of starting a clean new turn. This is the
307+
// consecutive-interrupt regression: pick turn-1's slot (kicks off the
308+
// resume), then immediately send turn-2's message — without waiting, turn-2
309+
// aborts the resume and the 2nd interrupt's card never mounts. Awaiting the
310+
// active run's completion serializes the two turns so the resume finishes
311+
// (graph completes) before the new message starts a fresh run.
312+
//
313+
// The completion promise lives only on `IntelligenceAgent` (via the
314+
// `RunCompletionAware` contract), not on the `AbstractAgent` type held here —
315+
// so it is reached through a type guard, not a cast. Agents that don't
316+
// implement the contract degrade safely (the await is skipped).
317+
const waitForActiveRunToSettle = useCallback(async () => {
318+
if (
319+
agent.isRunning &&
320+
isRunCompletionAware(agent) &&
321+
agent.activeRunCompletionPromise
322+
) {
323+
try {
324+
await agent.activeRunCompletionPromise;
325+
} catch (error) {
326+
// The in-flight run rejected — proceed with the new send anyway,
327+
// but log so a chronically-failing in-flight run is observable.
328+
console.error(
329+
"CopilotChat: in-flight run rejected while queuing send",
330+
error,
331+
);
332+
}
333+
}
334+
}, [agent]);
335+
298336
const onSubmitInput = useCallback(
299337
async (value: string) => {
300338
// Block if uploads in progress (fast fail against current state before
@@ -318,40 +356,8 @@ export function CopilotChat({
318356
setInputValue("");
319357

320358
// 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-
}
359+
// message instead of pre-empting it (see waitForActiveRunToSettle).
360+
await waitForActiveRunToSettle();
355361

356362
// Re-check the uploading guard against LIVE attachment state: an upload
357363
// can start (or stay in flight) during the await above, so a snapshot
@@ -409,11 +415,17 @@ export function CopilotChat({
409415
},
410416
// copilotkit is intentionally excluded — it is a stable ref that never changes.
411417
// eslint-disable-next-line react-hooks/exhaustive-deps
412-
[agent, consumeAttachments],
418+
[agent, consumeAttachments, waitForActiveRunToSettle],
413419
);
414420

415421
const handleSelectSuggestion = useCallback(
416422
async (suggestion: Suggestion) => {
423+
// Mirror onSubmitInput's send-serialization: if a run is in flight, wait
424+
// for it to settle before dispatching, so selecting a suggestion mid-run
425+
// does NOT pre-empt/abort the active run (the same #5195 fix the
426+
// typed-Enter path got — here for the suggestion path).
427+
await waitForActiveRunToSettle();
428+
417429
agent.addMessage({
418430
id: randomUUID(),
419431
role: "user",
@@ -430,7 +442,7 @@ export function CopilotChat({
430442
}
431443
},
432444
// eslint-disable-next-line react-hooks/exhaustive-deps
433-
[agent],
445+
[agent, waitForActiveRunToSettle],
434446
);
435447

436448
const stopCurrentRun = useCallback(() => {

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,7 @@ describe("CopilotChat attachments", () => {
221221
await waitFor(() => {
222222
expect(agent.isRunning).toBe(true);
223223
});
224-
(
225-
agent as unknown as { activeRunCompletionPromise?: Promise<void> }
226-
).activeRunCompletionPromise = inFlight;
224+
agent.activeRunCompletionPromise = inFlight;
227225

228226
// No attachments yet — the pre-await guard passes. Fire the send; it
229227
// parks on the in-flight run's completion promise.

0 commit comments

Comments
 (0)