feat(react-core): expose raw event metadata to feedback callbacks

This commit is contained in:
Rod Boev
2026-08-01 05:27:11 -04:00
parent 49b37fb195
commit f4519433ab
10 changed files with 542 additions and 4 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@copilotkit/core": minor
"@copilotkit/react-core": minor
---
feat(react-core): expose raw event metadata to feedback callbacks
@@ -0,0 +1,248 @@
import { AbstractAgent, EventType, transformChunks } from "@ag-ui/client";
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
import { firstValueFrom, of, toArray } from "rxjs";
import type { Observable } from "rxjs";
import { describe, expect, it } from "vitest";
import { CopilotKitCore } from "../core";
class RawEventAgent extends AbstractAgent {
readonly inputs: RunAgentInput[] = [];
constructor(
private readonly eventFactory: (input: RunAgentInput) => BaseEvent[],
agentId = "raw-event-agent",
threadId = "raw-event-thread",
) {
super({ agentId, threadId });
}
run(input: RunAgentInput): Observable<BaseEvent> {
this.inputs.push(input);
return of(...this.eventFactory(input));
}
}
function runEvents(input: RunAgentInput): BaseEvent[] {
return [
{
type: EventType.RUN_STARTED,
threadId: input.threadId,
runId: input.runId,
},
{
type: EventType.MESSAGES_SNAPSHOT,
rawEvent: { source: "snapshot" },
messages: [{ id: "snapshot-user", role: "user", content: "hello" }],
},
{
type: EventType.TEXT_MESSAGE_START,
messageId: "assistant-raw-event",
role: "assistant",
rawEvent: { langfuse_trace_id: "trace-3039" },
},
{
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: "assistant-raw-event",
delta: "answer",
},
{
type: EventType.TEXT_MESSAGE_END,
messageId: "assistant-raw-event",
},
{
type: EventType.RUN_FINISHED,
threadId: input.threadId,
runId: input.runId,
},
];
}
describe("StateManager direct text-start raw event sidecar", () => {
it("captures scoped metadata without changing canonical or outbound messages", async () => {
const agent = new RawEventAgent(runEvents);
const otherAgent = new RawEventAgent(
(input) => runEvents(input),
"other-agent",
"other-thread",
);
const core = new CopilotKitCore({
agents__unsafe_dev_only: {
[agent.agentId!]: agent,
[otherAgent.agentId!]: otherAgent,
},
});
let messageChanges = 0;
agent.subscribe({
onMessagesChanged: () => {
messageChanges++;
},
});
await agent.runAgent({ runId: "raw-event-run" });
expect(messageChanges).toBe(3);
expect(
core.getRawEventForMessage(
"raw-event-agent",
"raw-event-thread",
"assistant-raw-event",
),
).toEqual({ langfuse_trace_id: "trace-3039" });
expect(
core.getRawEventForMessage(
"raw-event-agent",
"other-thread",
"assistant-raw-event",
),
).toBeUndefined();
expect(
core.getRawEventForMessage(
"other-agent",
"other-thread",
"assistant-raw-event",
),
).toBeUndefined();
for (const message of agent.messages) {
expect(Object.prototype.hasOwnProperty.call(message, "rawEvent")).toBe(
false,
);
}
await agent.runAgent({ runId: "raw-event-run-2" });
for (const message of agent.inputs[1]?.messages ?? []) {
expect(Object.prototype.hasOwnProperty.call(message, "rawEvent")).toBe(
false,
);
}
});
it("preserves false, 0, empty string, and null while ignoring undefined", async () => {
const values: unknown[] = [false, 0, "", null, undefined];
const agent = new RawEventAgent((input) => [
{
type: EventType.RUN_STARTED,
threadId: input.threadId,
runId: input.runId,
},
...values.flatMap((rawEvent, index) => [
{
type: EventType.TEXT_MESSAGE_START,
messageId: `boundary-${index}`,
role: "assistant",
...(rawEvent === undefined ? {} : { rawEvent }),
} as BaseEvent,
{
type: EventType.TEXT_MESSAGE_END,
messageId: `boundary-${index}`,
} as BaseEvent,
]),
{
type: EventType.RUN_FINISHED,
threadId: input.threadId,
runId: input.runId,
},
]);
const core = new CopilotKitCore({
agents__unsafe_dev_only: { [agent.agentId!]: agent },
});
await agent.runAgent({ runId: "boundary-run" });
expect(
[0, 1, 2, 3].map((index) =>
core.getRawEventForMessage(
agent.agentId!,
agent.threadId,
`boundary-${index}`,
),
),
).toEqual([false, 0, "", null]);
expect(
core.getRawEventForMessage(agent.agentId!, agent.threadId, "boundary-4"),
).toBeUndefined();
});
it("prunes removed messages and leaves snapshot metadata outside the sidecar", async () => {
const agent = new RawEventAgent((input) => [
{
type: EventType.RUN_STARTED,
threadId: input.threadId,
runId: input.runId,
},
{
type: EventType.MESSAGES_SNAPSHOT,
rawEvent: { source: "snapshot" },
messages: [{ id: "snapshot-only", role: "user", content: "old" }],
},
{
type: EventType.TEXT_MESSAGE_START,
messageId: "pruned-message",
role: "assistant",
rawEvent: { source: "direct" },
},
{
type: EventType.TEXT_MESSAGE_END,
messageId: "pruned-message",
},
{
type: EventType.RUN_FINISHED,
threadId: input.threadId,
runId: input.runId,
},
]);
const core = new CopilotKitCore({
agents__unsafe_dev_only: { [agent.agentId!]: agent },
});
await agent.runAgent({ runId: "prune-run" });
expect(
core.getRawEventForMessage(
agent.agentId!,
agent.threadId,
"pruned-message",
),
).toEqual({ source: "direct" });
agent.setMessages([]);
await Promise.resolve();
expect(
core.getRawEventForMessage(
agent.agentId!,
agent.threadId,
"pruned-message",
),
).toBeUndefined();
expect(
core.getRawEventForMessage(
agent.agentId!,
agent.threadId,
"snapshot-only",
),
).toBeUndefined();
});
it("leaves TEXT_MESSAGE_CHUNK rawEvent outside the normalized start event", async () => {
const normalized = await firstValueFrom(
transformChunks()(
of({
type: EventType.TEXT_MESSAGE_CHUNK,
messageId: "chunk-message",
delta: "chunk",
rawEvent: { source: "chunk" },
}),
).pipe(toArray()),
);
expect(
normalized.some((event) => event.type === EventType.TEXT_MESSAGE_CHUNK),
).toBe(false);
expect(
normalized.find((event) => event.type === EventType.TEXT_MESSAGE_START),
).toMatchObject({ messageId: "chunk-message" });
expect(
normalized.find((event) => event.type === EventType.TEXT_MESSAGE_START),
).not.toHaveProperty("rawEvent");
});
});
+16
View File
@@ -528,6 +528,10 @@ export class CopilotKitCore {
this.previousAgentIds = currentAgentIds;
},
});
Object.values(agents__unsafe_dev_only).forEach((agent) => {
if (agent.agentId) this.stateManager.subscribeToAgent(agent);
});
}
/**
@@ -1327,6 +1331,18 @@ export class CopilotKitCore {
return this.stateManager.getRunIdForMessage(agentId, threadId, messageId);
}
getRawEventForMessage(
agentId: string,
threadId: string,
messageId: string,
): unknown {
return this.stateManager.getRawEventForMessage(
agentId,
threadId,
messageId,
);
}
getRunIdsForThread(agentId: string, threadId: string): string[] {
return this.stateManager.getRunIdsForThread(agentId, threadId);
}
+81 -1
View File
@@ -6,6 +6,7 @@ import type {
StateSnapshotEvent,
StateDeltaEvent,
MessagesSnapshotEvent,
TextMessageStartEvent,
} from "@ag-ui/client";
import { randomUUID } from "@ag-ui/client";
import type { CopilotKitCore } from "./core";
@@ -22,6 +23,10 @@ export class StateManager {
private messageToRun: Map<string, Map<string, Map<string, string>>> =
new Map();
// Direct text-start metadata: agentId -> threadId -> messageId -> rawEvent
private rawEventByMessage: Map<string, Map<string, Map<string, unknown>>> =
new Map();
// Active run tracking: `agentId:threadId` -> runId (used when messages arrive without input)
private activeRun: Map<string, string> = new Map();
@@ -117,6 +122,10 @@ export class StateManager {
if (revoked) return;
this.handleStateDelta(agent, event, effectiveInput(input), state);
},
onTextMessageStartEvent: ({ event, input }) => {
if (revoked) return;
this.handleTextMessageStart(agent, event, effectiveInput(input));
},
onMessagesSnapshotEvent: ({ event, input, messages }) => {
if (revoked) return;
this.handleMessagesSnapshot(
@@ -125,6 +134,12 @@ export class StateManager {
effectiveInput(input),
messages,
);
this.pruneRawEvents(
agent.agentId!,
input.threadId,
event.messages,
effectiveInput(input),
);
},
onNewMessage: ({ message, input }) => {
if (revoked) return;
@@ -134,6 +149,12 @@ export class StateManager {
input ? effectiveInput(input) : undefined,
);
},
onMessagesChanged: ({ messages, input }) => {
if (revoked) return;
if (!input) {
this.pruneRawEvents(agent.agentId!, agent.threadId, messages);
}
},
});
this.agentSubscriptions.set(agentId, () => {
@@ -151,6 +172,7 @@ export class StateManager {
unsubscribe();
this.agentSubscriptions.delete(agentId);
}
this.clearAgentState(agentId);
}
/**
@@ -179,6 +201,17 @@ export class StateManager {
return this.messageToRun.get(agentId)?.get(threadId)?.get(messageId);
}
/**
* Get direct text-start metadata associated with a message.
*/
getRawEventForMessage(
agentId: string,
threadId: string,
messageId: string,
): unknown {
return this.rawEventByMessage.get(agentId)?.get(threadId)?.get(messageId);
}
/**
* Get all states for an agent's thread
*/
@@ -265,6 +298,27 @@ export class StateManager {
this.saveState(agent.agentId, threadId, runId, state);
}
/**
* Capture only defined metadata from a normalized direct text-start event.
*/
private handleTextMessageStart(
agent: AbstractAgent,
event: TextMessageStartEvent,
input: RunAgentInput,
): void {
if (!agent.agentId || event.rawEvent === undefined) return;
const { threadId } = input;
if (!this.rawEventByMessage.has(agent.agentId)) {
this.rawEventByMessage.set(agent.agentId, new Map());
}
const agentEvents = this.rawEventByMessage.get(agent.agentId)!;
if (!agentEvents.has(threadId)) {
agentEvents.set(threadId, new Map());
}
agentEvents.get(threadId)!.set(event.messageId, event.rawEvent);
}
/**
* Handle messages snapshot event
*/
@@ -272,7 +326,7 @@ export class StateManager {
agent: AbstractAgent,
event: MessagesSnapshotEvent,
input: RunAgentInput,
messages: readonly Message[],
_messages: readonly Message[],
): void {
if (!agent.agentId) return;
@@ -361,12 +415,37 @@ export class StateManager {
threadMessages.set(messageId, runId);
}
private pruneRawEvents(
agentId: string,
fallbackThreadId: string | undefined,
messages: ReadonlyArray<Readonly<Message>>,
input?: RunAgentInput,
): void {
const threadId = input?.threadId ?? fallbackThreadId;
if (!threadId) return;
const threadEvents = this.rawEventByMessage.get(agentId)?.get(threadId);
if (!threadEvents) return;
const messageIds = new Set(messages.map((message) => message.id));
for (const messageId of threadEvents.keys()) {
if (!messageIds.has(messageId)) threadEvents.delete(messageId);
}
if (threadEvents.size === 0) {
this.rawEventByMessage.get(agentId)?.delete(threadId);
}
if (this.rawEventByMessage.get(agentId)?.size === 0) {
this.rawEventByMessage.delete(agentId);
}
}
/**
* Clear all state for an agent
*/
clearAgentState(agentId: string): void {
this.stateByRun.delete(agentId);
this.messageToRun.delete(agentId);
this.rawEventByMessage.delete(agentId);
}
/**
@@ -375,5 +454,6 @@ export class StateManager {
clearThreadState(agentId: string, threadId: string): void {
this.stateByRun.get(agentId)?.delete(threadId);
this.messageToRun.get(agentId)?.delete(threadId);
this.rawEventByMessage.get(agentId)?.delete(threadId);
}
}
@@ -106,3 +106,12 @@ your task — do not try to absorb the whole package from this file.
3. `agent-access` — talk to agents.
4. `client-side-tools` + `rendering-tool-calls` — add tool-call UI.
5. Anything else as your feature requires.
## Feedback metadata
React v2 thumbs callbacks receive `CopilotChatFeedbackMessage`. For a live
assistant message created by a direct AG-UI `TEXT_MESSAGE_START` event, the
callback argument can include that event's opaque `rawEvent` value. The value
is joined at click time and stays out of canonical messages, rendering props,
and future run input. Chunk, snapshot, persisted, and legacy message paths do
not provide this metadata.
@@ -97,6 +97,27 @@ export function HeadlessChat() {
/>
```
### Feedback event metadata
Pass thumbs callbacks through the `assistantMessage` slot when a live direct
AG-UI text-start event's metadata is needed:
```tsx
<CopilotChatMessageView
messages={agent.messages}
assistantMessage={{
onThumbsUp: (message) => {
console.log(message.rawEvent);
},
}}
/>
```
The callback receives the same assistant message ID and a callback-only
`rawEvent` value when the message came from a direct `TEXT_MESSAGE_START`.
Canonical messages and future run input remain protocol-clean. Chunk and
snapshot metadata aren't attributed to feedback messages.
## Common Mistakes
### CRITICAL — Importing `CopilotPanel`
@@ -26,6 +26,10 @@ import { Streamdown } from "streamdown";
import { copyToClipboard } from "@copilotkit/shared";
import CopilotChatToolCallsView from "./CopilotChatToolCallsView";
export type CopilotChatFeedbackMessage = AssistantMessage & {
rawEvent?: unknown;
};
export type CopilotChatAssistantMessageProps = WithSlots<
{
markdownRenderer: typeof CopilotChatAssistantMessage.MarkdownRenderer;
@@ -38,8 +42,8 @@ export type CopilotChatAssistantMessageProps = WithSlots<
toolCallsView: typeof CopilotChatToolCallsView;
},
{
onThumbsUp?: (message: AssistantMessage) => void;
onThumbsDown?: (message: AssistantMessage) => void;
onThumbsUp?: (message: CopilotChatFeedbackMessage) => void;
onThumbsDown?: (message: CopilotChatFeedbackMessage) => void;
onReadAloud?: (message: AssistantMessage) => void;
onRegenerate?: (message: AssistantMessage) => void;
message: AssistantMessage;
@@ -11,6 +11,7 @@ import { ScrollElementContext } from "./scroll-element-context";
import type { WithSlots } from "../../lib/slots";
import { renderSlot, isReactComponentType } from "../../lib/slots";
import CopilotChatAssistantMessage from "./CopilotChatAssistantMessage";
import type { CopilotChatFeedbackMessage } from "./CopilotChatAssistantMessage";
import CopilotChatUserMessage from "./CopilotChatUserMessage";
import CopilotChatReasoningMessage from "./CopilotChatReasoningMessage";
import type {
@@ -465,6 +466,40 @@ export function CopilotChatMessageView({
() => resolveSlotComponent(assistantMessage, CopilotChatAssistantMessage),
[assistantMessage],
);
const assistantSlotPropsWithFeedback = useMemo(() => {
const onThumbsUp = assistantSlotProps?.onThumbsUp as
| ((message: CopilotChatFeedbackMessage) => void)
| undefined;
const onThumbsDown = assistantSlotProps?.onThumbsDown as
| ((message: CopilotChatFeedbackMessage) => void)
| undefined;
if (!onThumbsUp && !onThumbsDown) return assistantSlotProps;
const withRawEvent = (
message: AssistantMessage,
): CopilotChatFeedbackMessage => {
const rawEvent = config
? copilotkit.getRawEventForMessage(
config.agentId,
config.threadId,
message.id,
)
: undefined;
return rawEvent === undefined ? message : { ...message, rawEvent };
};
return {
...assistantSlotProps,
...(onThumbsUp && {
onThumbsUp: (message: AssistantMessage) =>
onThumbsUp(withRawEvent(message)),
}),
...(onThumbsDown && {
onThumbsDown: (message: AssistantMessage) =>
onThumbsDown(withRawEvent(message)),
}),
};
}, [assistantSlotProps, config, copilotkit]);
const { Component: UserComponent, slotProps: userSlotProps } = useMemo(
() => resolveSlotComponent(userMessage, CopilotChatUserMessage),
[userMessage],
@@ -578,7 +613,7 @@ export function CopilotChatMessageView({
messages={messages}
isRunning={isRunning}
AssistantMessageComponent={AssistantComponent}
slotProps={assistantSlotProps}
slotProps={assistantSlotPropsWithFeedback}
/>,
);
} else if (message.role === "user") {
@@ -0,0 +1,118 @@
import React from "react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { AbstractAgent, EventType } from "@ag-ui/client";
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
import { of } from "rxjs";
import type { Observable } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
import { CopilotChatMessageView } from "../CopilotChatMessageView";
class FeedbackAgent extends AbstractAgent {
readonly inputs: RunAgentInput[] = [];
readonly rawEvent = { langfuse_trace_id: "trace-3039" };
constructor() {
super({ agentId: "feedback-agent", threadId: "feedback-thread" });
}
run(input: RunAgentInput): Observable<BaseEvent> {
this.inputs.push(input);
const messageId = `assistant-${this.inputs.length}`;
return of(
{
type: EventType.RUN_STARTED,
threadId: input.threadId,
runId: input.runId,
},
{
type: EventType.TEXT_MESSAGE_START,
messageId,
role: "assistant",
rawEvent: this.rawEvent,
},
{
type: EventType.TEXT_MESSAGE_CONTENT,
messageId,
delta: "Answer",
},
{ type: EventType.TEXT_MESSAGE_END, messageId },
{
type: EventType.RUN_FINISHED,
threadId: input.threadId,
runId: input.runId,
},
);
}
}
describe("CopilotChatMessageView feedback raw event", () => {
it("joins direct start metadata at the real thumbs callback boundary", async () => {
const agent = new FeedbackAgent();
const onThumbsUp = vi.fn();
const onThumbsDown = vi.fn();
const view = render(
<CopilotKitProvider agents__unsafe_dev_only={{ "feedback-agent": agent }}>
<CopilotChatConfigurationProvider
agentId="feedback-agent"
threadId="feedback-thread"
>
<CopilotChatMessageView
messages={agent.messages}
assistantMessage={{ onThumbsUp, onThumbsDown }}
/>
</CopilotChatConfigurationProvider>
</CopilotKitProvider>,
);
await act(async () => {
await agent.runAgent({ runId: "feedback-run-1" });
});
expect(agent.rawEvent).toEqual({ langfuse_trace_id: "trace-3039" });
view.rerender(
<CopilotKitProvider agents__unsafe_dev_only={{ "feedback-agent": agent }}>
<CopilotChatConfigurationProvider
agentId="feedback-agent"
threadId="feedback-thread"
>
<CopilotChatMessageView
messages={agent.messages}
assistantMessage={{ onThumbsUp, onThumbsDown }}
/>
</CopilotChatConfigurationProvider>
</CopilotKitProvider>,
);
fireEvent.click(screen.getByRole("button", { name: /good response/i }));
fireEvent.click(screen.getByRole("button", { name: /bad response/i }));
expect(onThumbsUp).toHaveBeenCalledWith(
expect.objectContaining({
id: "assistant-1",
rawEvent: { langfuse_trace_id: "trace-3039" },
}),
);
expect(onThumbsDown).toHaveBeenCalledWith(
expect.objectContaining({
id: "assistant-1",
rawEvent: { langfuse_trace_id: "trace-3039" },
}),
);
expect(onThumbsUp.mock.calls[0]?.[0]).not.toBe(agent.messages[0]);
for (const message of agent.messages) {
expect(Object.prototype.hasOwnProperty.call(message, "rawEvent")).toBe(
false,
);
}
await act(async () => {
await agent.runAgent({ runId: "feedback-run-2" });
});
expect(
agent.inputs[1]?.messages.some((message) =>
Object.prototype.hasOwnProperty.call(message, "rawEvent"),
),
).toBe(false);
});
});
@@ -7,6 +7,7 @@ export {
export {
default as CopilotChatAssistantMessage,
type CopilotChatAssistantMessageProps,
type CopilotChatFeedbackMessage,
} from "./CopilotChatAssistantMessage";
export {