UNPKG

@copilotkit/shared

Version:

<img src="https://github.com/user-attachments/assets/0a6b64d9-e193-4940-a3f6-60334ac34084" alt="banner" style="border-radius: 12px; border: 2px solid #d6d4fa;" />

165 lines (150 loc) 4.94 kB
import type { BaseEvent, RunErrorEvent } from "@ag-ui/client"; import { EventType } from "@ag-ui/client"; import { randomUUID } from "./utils"; interface FinalizeRunOptions { stopRequested?: boolean; interruptionMessage?: string; } const defaultStopMessage = "Run stopped by user"; const defaultAbruptEndMessage = "Run ended without emitting a terminal event"; export function finalizeRunEvents( events: BaseEvent[], options: FinalizeRunOptions = {}, ): BaseEvent[] { const { stopRequested = false, interruptionMessage } = options; const resolvedStopMessage = interruptionMessage ?? defaultStopMessage; const resolvedAbruptMessage = interruptionMessage && interruptionMessage !== defaultStopMessage ? interruptionMessage : defaultAbruptEndMessage; const appended: BaseEvent[] = []; const openMessageIds = new Set<string>(); const openToolCalls = new Map< string, { hasEnd: boolean; hasResult: boolean; } >(); for (const event of events) { switch (event.type) { case EventType.TEXT_MESSAGE_START: { const messageId = (event as { messageId?: string }).messageId; if (typeof messageId === "string") { openMessageIds.add(messageId); } break; } case EventType.TEXT_MESSAGE_END: { const messageId = (event as { messageId?: string }).messageId; if (typeof messageId === "string") { openMessageIds.delete(messageId); } break; } case EventType.TOOL_CALL_START: { const toolCallId = (event as { toolCallId?: string }).toolCallId; if (typeof toolCallId === "string") { openToolCalls.set(toolCallId, { hasEnd: false, hasResult: false, }); } break; } case EventType.TOOL_CALL_END: { const toolCallId = (event as { toolCallId?: string }).toolCallId; const info = toolCallId ? openToolCalls.get(toolCallId) : undefined; if (info) { info.hasEnd = true; } break; } case EventType.TOOL_CALL_RESULT: { const toolCallId = (event as { toolCallId?: string }).toolCallId; const info = toolCallId ? openToolCalls.get(toolCallId) : undefined; if (info) { info.hasResult = true; } break; } default: break; } } const hasTerminalEvent = events.some( (event) => event.type === EventType.RUN_FINISHED || event.type === EventType.RUN_ERROR, ); // Once a run has emitted a terminal event (RUN_FINISHED or RUN_ERROR), the // AG-UI spec forbids any further events for that run — the verifier rejects a // trailing TEXT_MESSAGE_END / TOOL_CALL_END with "the run has already // errored/finished with '…'. No further events can be sent." (issue #5812). // These finalization events are streamed *after* everything the agent already // emitted, so appending closers here would place them after that terminal. // When a terminal already exists we therefore append nothing: any message or // tool call still open when the terminal arrived is implicitly closed by the // terminal on the client. We only synthesize closers + a terminal for a // stream that ended abruptly without a terminal of its own (below). if (hasTerminalEvent) { return appended; } for (const messageId of openMessageIds) { const endEvent = { type: EventType.TEXT_MESSAGE_END, messageId, } as BaseEvent; events.push(endEvent); appended.push(endEvent); } for (const [toolCallId, info] of openToolCalls) { if (!info.hasEnd) { const endEvent = { type: EventType.TOOL_CALL_END, toolCallId, } as BaseEvent; events.push(endEvent); appended.push(endEvent); } if (!info.hasResult) { const resultEvent = { type: EventType.TOOL_CALL_RESULT, toolCallId, messageId: `${toolCallId ?? randomUUID()}-result`, role: "tool", content: JSON.stringify( stopRequested ? { status: "stopped", reason: "stop_requested", message: resolvedStopMessage, } : { status: "error", reason: "missing_terminal_event", message: resolvedAbruptMessage, }, ), } as BaseEvent; events.push(resultEvent); appended.push(resultEvent); } } if (stopRequested) { const finishedEvent = { type: EventType.RUN_FINISHED, } as BaseEvent; events.push(finishedEvent); appended.push(finishedEvent); } else { const errorEvent: RunErrorEvent = { type: EventType.RUN_ERROR, message: resolvedAbruptMessage, code: "INCOMPLETE_STREAM", }; events.push(errorEvent); appended.push(errorEvent); } return appended; }