Update from Vibe Studio

This commit is contained in:
Vibe Studio
2026-01-09 14:52:46 +00:00
parent 42a0efe70b
commit 47fa6d98b2
28661 changed files with 2421771 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
import { Client, cacheExchange, fetchExchange } from "@urql/core";
import * as packageJson from "../../package.json";
import {
AvailableAgentsQuery,
GenerateCopilotResponseMutation,
GenerateCopilotResponseMutationVariables,
LoadAgentStateQuery,
} from "../graphql/@generated/graphql";
import { generateCopilotResponseMutation } from "../graphql/definitions/mutations";
import { getAvailableAgentsQuery, loadAgentStateQuery } from "../graphql/definitions/queries";
import { OperationResultSource, OperationResult } from "urql";
import {
ResolvedCopilotKitError,
CopilotKitLowLevelError,
CopilotKitError,
CopilotKitVersionMismatchError,
getPossibleVersionMismatch,
} from "@copilotkit/shared";
const createFetchFn =
(signal?: AbortSignal, handleGQLWarning?: (warning: string) => void) =>
async (...args: Parameters<typeof fetch>) => {
// @ts-expect-error -- since this is our own header, TS will not recognize
const publicApiKey = args[1]?.headers?.["x-copilotcloud-public-api-key"];
try {
const result = await fetch(args[0], { ...(args[1] ?? {}), signal });
// No mismatch checking if cloud is being used
const mismatch = publicApiKey
? null
: await getPossibleVersionMismatch({
runtimeVersion: result.headers.get("X-CopilotKit-Runtime-Version")!,
runtimeClientGqlVersion: packageJson.version,
});
if (result.status !== 200) {
if (result.status >= 400 && result.status <= 500) {
if (mismatch) {
throw new CopilotKitVersionMismatchError(mismatch);
}
throw new ResolvedCopilotKitError({ status: result.status });
}
}
if (mismatch && handleGQLWarning) {
handleGQLWarning(mismatch.message);
}
return result;
} catch (error) {
// Let abort error pass through. It will be suppressed later
if (
(error as Error).message.includes("BodyStreamBuffer was aborted") ||
(error as Error).message.includes("signal is aborted without reason")
) {
throw error;
}
if (error instanceof CopilotKitError) {
throw error;
}
throw new CopilotKitLowLevelError({ error: error as Error, url: args[0] as string });
}
};
export interface CopilotRuntimeClientOptions {
url: string;
publicApiKey?: string;
headers?: Record<string, string>;
credentials?: RequestCredentials;
handleGQLErrors?: (error: Error) => void;
handleGQLWarning?: (warning: string) => void;
}
export class CopilotRuntimeClient {
client: Client;
public handleGQLErrors?: (error: Error) => void;
public handleGQLWarning?: (warning: string) => void;
constructor(options: CopilotRuntimeClientOptions) {
const headers: Record<string, string> = {};
this.handleGQLErrors = options.handleGQLErrors;
this.handleGQLWarning = options.handleGQLWarning;
if (options.headers) {
Object.assign(headers, options.headers);
}
if (options.publicApiKey) {
headers["x-copilotcloud-public-api-key"] = options.publicApiKey;
}
this.client = new Client({
url: options.url,
exchanges: [cacheExchange, fetchExchange],
fetchOptions: {
headers: {
...headers,
"X-CopilotKit-Runtime-Client-GQL-Version": packageJson.version,
},
...(options.credentials ? { credentials: options.credentials } : {}),
},
});
}
generateCopilotResponse({
data,
properties,
signal,
}: {
data: GenerateCopilotResponseMutationVariables["data"];
properties?: GenerateCopilotResponseMutationVariables["properties"];
signal?: AbortSignal;
}) {
const fetchFn = createFetchFn(signal, this.handleGQLWarning);
const result = this.client.mutation<
GenerateCopilotResponseMutation,
GenerateCopilotResponseMutationVariables
>(generateCopilotResponseMutation, { data, properties }, { fetch: fetchFn });
return result;
}
public asStream<S, T>(source: OperationResultSource<OperationResult<S, { data: T }>>) {
const handleGQLErrors = this.handleGQLErrors;
return new ReadableStream<S>({
start(controller) {
source.subscribe(({ data, hasNext, error }) => {
if (error) {
if (
error.message.includes("BodyStreamBuffer was aborted") ||
error.message.includes("signal is aborted without reason")
) {
// close the stream if there is no next item
if (!hasNext) controller.close();
//suppress this specific error
console.warn("Abort error suppressed");
return;
}
// Handle structured errors specially - check if it's a CopilotKitError with visibility
if ((error as any).extensions?.visibility) {
// Create a synthetic GraphQL error with the structured error info
const syntheticError = {
...error,
graphQLErrors: [
{
message: error.message,
extensions: (error as any).extensions,
},
],
};
if (handleGQLErrors) {
handleGQLErrors(syntheticError);
}
return; // Don't close the stream for structured errors, let the error handler decide
}
controller.error(error);
if (handleGQLErrors) {
handleGQLErrors(error);
}
} else {
controller.enqueue(data);
if (!hasNext) {
controller.close();
}
}
});
},
});
}
availableAgents() {
const fetchFn = createFetchFn();
return this.client.query<AvailableAgentsQuery>(getAvailableAgentsQuery, {}, { fetch: fetchFn });
}
loadAgentState(data: { threadId: string; agentName: string }) {
const fetchFn = createFetchFn();
const result = this.client.query<LoadAgentStateQuery>(
loadAgentStateQuery,
{ data },
{ fetch: fetchFn },
);
// Add error handling for GraphQL errors - similar to generateCopilotResponse
result
.toPromise()
.then(({ error }) => {
if (error && this.handleGQLErrors) {
this.handleGQLErrors(error);
}
})
.catch(() => {}); // Suppress promise rejection warnings
return result;
}
static removeGraphQLTypename(data: any) {
if (Array.isArray(data)) {
data.forEach((item) => CopilotRuntimeClient.removeGraphQLTypename(item));
} else if (typeof data === "object" && data !== null) {
delete data.__typename;
Object.keys(data).forEach((key) => {
if (typeof data[key] === "object" && data[key] !== null) {
CopilotRuntimeClient.removeGraphQLTypename(data[key]);
}
});
}
return data;
}
}

View File

@@ -0,0 +1,248 @@
import {
GenerateCopilotResponseMutation,
MessageInput,
MessageStatusCode,
} from "../graphql/@generated/graphql";
import {
ActionExecutionMessage,
AgentStateMessage,
Message,
ResultMessage,
TextMessage,
ImageMessage,
} from "./types";
import untruncateJson from "untruncate-json";
import { parseJson } from "@copilotkit/shared";
export function filterAgentStateMessages(messages: Message[]): Message[] {
return messages.filter((message) => !message.isAgentStateMessage());
}
export function convertMessagesToGqlInput(messages: Message[]): MessageInput[] {
return messages.map((message) => {
if (message.isTextMessage()) {
return {
id: message.id,
createdAt: message.createdAt,
textMessage: {
content: message.content,
role: message.role as any,
parentMessageId: message.parentMessageId,
},
};
} else if (message.isActionExecutionMessage()) {
return {
id: message.id,
createdAt: message.createdAt,
actionExecutionMessage: {
name: message.name,
arguments: JSON.stringify(message.arguments),
parentMessageId: message.parentMessageId,
},
};
} else if (message.isResultMessage()) {
return {
id: message.id,
createdAt: message.createdAt,
resultMessage: {
result: message.result,
actionExecutionId: message.actionExecutionId,
actionName: message.actionName,
},
};
} else if (message.isAgentStateMessage()) {
return {
id: message.id,
createdAt: message.createdAt,
agentStateMessage: {
threadId: message.threadId,
role: message.role,
agentName: message.agentName,
nodeName: message.nodeName,
runId: message.runId,
active: message.active,
running: message.running,
state: JSON.stringify(message.state),
},
};
} else if (message.isImageMessage()) {
return {
id: message.id,
createdAt: message.createdAt,
imageMessage: {
format: message.format,
bytes: message.bytes,
role: message.role as any,
parentMessageId: message.parentMessageId,
},
};
} else {
throw new Error("Unknown message type");
}
});
}
export function filterAdjacentAgentStateMessages(
messages: GenerateCopilotResponseMutation["generateCopilotResponse"]["messages"],
): GenerateCopilotResponseMutation["generateCopilotResponse"]["messages"] {
const filteredMessages: GenerateCopilotResponseMutation["generateCopilotResponse"]["messages"] =
[];
messages.forEach((message, i) => {
// keep all other message types
if (message.__typename !== "AgentStateMessageOutput") {
filteredMessages.push(message);
} else {
const prevAgentStateMessageIndex = filteredMessages.findIndex(
// TODO: also check runId
(m) => m.__typename === "AgentStateMessageOutput" && m.agentName === message.agentName,
);
if (prevAgentStateMessageIndex === -1) {
filteredMessages.push(message);
} else {
filteredMessages[prevAgentStateMessageIndex] = message;
}
}
});
return filteredMessages;
}
export function convertGqlOutputToMessages(
messages: GenerateCopilotResponseMutation["generateCopilotResponse"]["messages"],
): Message[] {
return messages.map((message) => {
if (message.__typename === "TextMessageOutput") {
return new TextMessage({
id: message.id,
role: message.role,
content: message.content.join(""),
parentMessageId: message.parentMessageId,
createdAt: new Date(),
status: message.status || { code: MessageStatusCode.Pending },
});
} else if (message.__typename === "ActionExecutionMessageOutput") {
return new ActionExecutionMessage({
id: message.id,
name: message.name,
arguments: getPartialArguments(message.arguments),
parentMessageId: message.parentMessageId,
createdAt: new Date(),
status: message.status || { code: MessageStatusCode.Pending },
});
} else if (message.__typename === "ResultMessageOutput") {
return new ResultMessage({
id: message.id,
result: message.result,
actionExecutionId: message.actionExecutionId,
actionName: message.actionName,
createdAt: new Date(),
status: message.status || { code: MessageStatusCode.Pending },
});
} else if (message.__typename === "AgentStateMessageOutput") {
return new AgentStateMessage({
id: message.id,
threadId: message.threadId,
role: message.role,
agentName: message.agentName,
nodeName: message.nodeName,
runId: message.runId,
active: message.active,
running: message.running,
state: parseJson(message.state, {}),
createdAt: new Date(),
});
} else if (message.__typename === "ImageMessageOutput") {
return new ImageMessage({
id: message.id,
format: message.format,
bytes: message.bytes,
role: message.role,
parentMessageId: message.parentMessageId,
createdAt: new Date(),
status: message.status || { code: MessageStatusCode.Pending },
});
}
throw new Error("Unknown message type");
});
}
export function loadMessagesFromJsonRepresentation(json: any[]): Message[] {
const result: Message[] = [];
for (const item of json) {
if ("content" in item) {
result.push(
new TextMessage({
id: item.id,
role: item.role,
content: item.content,
parentMessageId: item.parentMessageId,
createdAt: item.createdAt || new Date(),
status: item.status || { code: MessageStatusCode.Success },
}),
);
} else if ("arguments" in item) {
result.push(
new ActionExecutionMessage({
id: item.id,
name: item.name,
arguments: item.arguments,
parentMessageId: item.parentMessageId,
createdAt: item.createdAt || new Date(),
status: item.status || { code: MessageStatusCode.Success },
}),
);
} else if ("result" in item) {
result.push(
new ResultMessage({
id: item.id,
result: item.result,
actionExecutionId: item.actionExecutionId,
actionName: item.actionName,
createdAt: item.createdAt || new Date(),
status: item.status || { code: MessageStatusCode.Success },
}),
);
} else if ("state" in item) {
result.push(
new AgentStateMessage({
id: item.id,
threadId: item.threadId,
role: item.role,
agentName: item.agentName,
nodeName: item.nodeName,
runId: item.runId,
active: item.active,
running: item.running,
state: item.state,
createdAt: item.createdAt || new Date(),
}),
);
} else if ("format" in item && "bytes" in item) {
result.push(
new ImageMessage({
id: item.id,
format: item.format,
bytes: item.bytes,
role: item.role,
parentMessageId: item.parentMessageId,
createdAt: item.createdAt || new Date(),
status: item.status || { code: MessageStatusCode.Success },
}),
);
}
}
return result;
}
function getPartialArguments(args: string[]) {
try {
if (!args.length) return {};
return JSON.parse(untruncateJson(args.join("")));
} catch (e) {
return {};
}
}

View File

@@ -0,0 +1,10 @@
export * from "./CopilotRuntimeClient";
export {
convertMessagesToGqlInput,
convertGqlOutputToMessages,
filterAdjacentAgentStateMessages,
filterAgentStateMessages,
loadMessagesFromJsonRepresentation,
} from "./conversion";
export * from "./types";
export type { GraphQLError } from "graphql";

View File

@@ -0,0 +1,168 @@
import { randomId } from "@copilotkit/shared";
import {
ActionExecutionMessageInput,
MessageRole,
MessageStatus,
ResultMessageInput,
TextMessageInput,
BaseMessageOutput,
AgentStateMessageInput,
MessageStatusCode,
LangGraphInterruptEvent as GqlLangGraphInterruptEvent,
MetaEventName,
CopilotKitLangGraphInterruptEvent as GqlCopilotKitLangGraphInterruptEvent,
ImageMessageInput,
} from "../graphql/@generated/graphql";
import { parseJson } from "@copilotkit/shared";
type MessageType =
| "TextMessage"
| "ActionExecutionMessage"
| "ResultMessage"
| "AgentStateMessage"
| "ImageMessage";
export class Message {
type: MessageType;
id: BaseMessageOutput["id"];
createdAt: BaseMessageOutput["createdAt"];
status: MessageStatus;
constructor(props: any) {
props.id ??= randomId();
props.status ??= { code: MessageStatusCode.Success };
props.createdAt ??= new Date();
Object.assign(this, props);
}
isTextMessage(): this is TextMessage {
return this.type === "TextMessage";
}
isActionExecutionMessage(): this is ActionExecutionMessage {
return this.type === "ActionExecutionMessage";
}
isResultMessage(): this is ResultMessage {
return this.type === "ResultMessage";
}
isAgentStateMessage(): this is AgentStateMessage {
return this.type === "AgentStateMessage";
}
isImageMessage(): this is ImageMessage {
return this.type === "ImageMessage";
}
}
// alias Role to MessageRole
export const Role = MessageRole;
// when constructing any message, the base fields are optional
type MessageConstructorOptions = Partial<Message>;
type TextMessageConstructorOptions = MessageConstructorOptions & TextMessageInput;
export class TextMessage extends Message implements TextMessageConstructorOptions {
role: TextMessageInput["role"];
content: TextMessageInput["content"];
parentMessageId: TextMessageInput["parentMessageId"];
constructor(props: TextMessageConstructorOptions) {
super(props);
this.type = "TextMessage";
}
}
type ActionExecutionMessageConstructorOptions = MessageConstructorOptions &
Omit<ActionExecutionMessageInput, "arguments"> & {
arguments: Record<string, any>;
};
export class ActionExecutionMessage
extends Message
implements Omit<ActionExecutionMessageInput, "arguments" | "scope">
{
name: ActionExecutionMessageInput["name"];
arguments: Record<string, any>;
parentMessageId: ActionExecutionMessageInput["parentMessageId"];
constructor(props: ActionExecutionMessageConstructorOptions) {
super(props);
this.type = "ActionExecutionMessage";
}
}
type ResultMessageConstructorOptions = MessageConstructorOptions & ResultMessageInput;
export class ResultMessage extends Message implements ResultMessageConstructorOptions {
actionExecutionId: ResultMessageInput["actionExecutionId"];
actionName: ResultMessageInput["actionName"];
result: ResultMessageInput["result"];
constructor(props: ResultMessageConstructorOptions) {
super(props);
this.type = "ResultMessage";
}
static decodeResult(result: string): any {
return parseJson(result, result);
}
static encodeResult(result: any): string {
if (result === undefined) {
return "";
} else if (typeof result === "string") {
return result;
} else {
return JSON.stringify(result);
}
}
}
export class AgentStateMessage extends Message implements Omit<AgentStateMessageInput, "state"> {
agentName: AgentStateMessageInput["agentName"];
state: any;
running: AgentStateMessageInput["running"];
threadId: AgentStateMessageInput["threadId"];
role: AgentStateMessageInput["role"];
nodeName: AgentStateMessageInput["nodeName"];
runId: AgentStateMessageInput["runId"];
active: AgentStateMessageInput["active"];
constructor(props: any) {
super(props);
this.type = "AgentStateMessage";
}
}
type ImageMessageConstructorOptions = MessageConstructorOptions & ImageMessageInput;
export class ImageMessage extends Message implements ImageMessageConstructorOptions {
format: ImageMessageInput["format"];
bytes: ImageMessageInput["bytes"];
role: ImageMessageInput["role"];
parentMessageId: ImageMessageInput["parentMessageId"];
constructor(props: ImageMessageConstructorOptions) {
super(props);
this.type = "ImageMessage";
}
}
export function langGraphInterruptEvent(
eventProps: Omit<LangGraphInterruptEvent, "name" | "type" | "__typename">,
): LangGraphInterruptEvent {
return { ...eventProps, name: MetaEventName.LangGraphInterruptEvent, type: "MetaEvent" };
}
export type LangGraphInterruptEvent<TValue extends any = any> = GqlLangGraphInterruptEvent & {
value: TValue;
};
type CopilotKitLangGraphInterruptEvent<TValue extends any = any> =
GqlCopilotKitLangGraphInterruptEvent & {
data: GqlCopilotKitLangGraphInterruptEvent["data"] & { value: TValue };
};
export type MetaEvent = LangGraphInterruptEvent | CopilotKitLangGraphInterruptEvent;

View File

@@ -0,0 +1,87 @@
/* eslint-disable */
import type { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
import type { FragmentDefinitionNode } from 'graphql';
import type { Incremental } from './graphql';
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration<
infer TType,
any
>
? [TType] extends [{ ' $fragmentName'?: infer TKey }]
? TKey extends string
? { ' $fragmentRefs'?: { [key in TKey]: TType } }
: never
: never
: never;
// return non-nullable if `fragmentType` is non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>>
): TType;
// return nullable if `fragmentType` is undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | undefined
): TType | undefined;
// return nullable if `fragmentType` is nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null
): TType | null;
// return nullable if `fragmentType` is nullable or undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null | undefined
): TType | null | undefined;
// return array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>>
): Array<TType>;
// return array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): Array<TType> | null | undefined;
// return readonly array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
): ReadonlyArray<TType>;
// return readonly array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): ReadonlyArray<TType> | null | undefined;
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | Array<FragmentType<DocumentTypeDecoration<TType, any>>> | ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): TType | Array<TType> | ReadonlyArray<TType> | null | undefined {
return fragmentType as any;
}
export function makeFragmentData<
F extends DocumentTypeDecoration<any, any>,
FT extends ResultOf<F>
>(data: FT, _fragment: F): FragmentType<F> {
return data as FragmentType<F>;
}
export function isFragmentReady<TQuery, TFrag>(
queryNode: DocumentTypeDecoration<TQuery, any>,
fragmentNode: TypedDocumentNode<TFrag>,
data: FragmentType<TypedDocumentNode<Incremental<TFrag>, any>> | null | undefined
): data is FragmentType<typeof fragmentNode> {
const deferredFields = (queryNode as { __meta__?: { deferredFields: Record<string, (keyof TFrag)[]> } }).__meta__
?.deferredFields;
if (!deferredFields) return true;
const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined;
const fragName = fragDef?.name?.value;
const fields = (fragName && deferredFields[fragName]) || [];
return fields.length > 0 && fields.every(field => data && field in data);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
export * from "./fragment-masking";
export * from "./gql";

View File

@@ -0,0 +1,130 @@
import { graphql } from "../@generated/gql";
export const generateCopilotResponseMutation = graphql(/** GraphQL **/ `
mutation generateCopilotResponse($data: GenerateCopilotResponseInput!, $properties: JSONObject) {
generateCopilotResponse(data: $data, properties: $properties) {
threadId
runId
extensions {
openaiAssistantAPI {
runId
threadId
}
}
... on CopilotResponse @defer {
status {
... on BaseResponseStatus {
code
}
... on FailedResponseStatus {
reason
details
}
}
}
messages @stream {
__typename
... on BaseMessageOutput {
id
createdAt
}
... on BaseMessageOutput @defer {
status {
... on SuccessMessageStatus {
code
}
... on FailedMessageStatus {
code
reason
}
... on PendingMessageStatus {
code
}
}
}
... on TextMessageOutput {
content @stream
role
parentMessageId
}
... on ImageMessageOutput {
format
bytes
role
parentMessageId
}
... on ActionExecutionMessageOutput {
name
arguments @stream
parentMessageId
}
... on ResultMessageOutput {
result
actionExecutionId
actionName
}
... on AgentStateMessageOutput {
threadId
state
running
agentName
nodeName
runId
active
role
}
}
metaEvents @stream {
... on LangGraphInterruptEvent {
type
name
value
}
... on CopilotKitLangGraphInterruptEvent {
type
name
data {
messages {
__typename
... on BaseMessageOutput {
id
createdAt
}
... on BaseMessageOutput @defer {
status {
... on SuccessMessageStatus {
code
}
... on FailedMessageStatus {
code
reason
}
... on PendingMessageStatus {
code
}
}
}
... on TextMessageOutput {
content
role
parentMessageId
}
... on ActionExecutionMessageOutput {
name
arguments
parentMessageId
}
... on ResultMessageOutput {
result
actionExecutionId
actionName
}
}
value
}
}
}
}
}
`);

View File

@@ -0,0 +1,24 @@
import { graphql } from "../@generated/gql";
export const getAvailableAgentsQuery = graphql(/** GraphQL **/ `
query availableAgents {
availableAgents {
agents {
name
id
description
}
}
}
`);
export const loadAgentStateQuery = graphql(/** GraphQL **/ `
query loadAgentState($data: LoadAgentStateInput!) {
loadAgentState(data: $data) {
threadId
threadExists
state
messages
}
}
`);

View File

@@ -0,0 +1,4 @@
export * from "./client";
export * from "./graphql/@generated/graphql";
export * from "./message-conversion";
export type { LangGraphInterruptEvent } from "./client";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,302 @@
import * as gql from "../client";
import { MessageRole } from "../graphql/@generated/graphql";
import agui from "@copilotkit/shared"; // named agui for clarity, but this only includes agui message types
// Helper function to extract agent name from message
function extractAgentName(message: agui.Message): string {
if (message.role !== "assistant") {
throw new Error(`Cannot extract agent name from message with role ${message.role}`);
}
return message.agentName || "unknown";
}
// Type guard for agent state message
function isAgentStateMessage(message: agui.Message): boolean {
return message.role === "assistant" && "agentName" in message && "state" in message;
}
// Type guard for messages with image property
function hasImageProperty(message: agui.Message): boolean {
const canContainImage = message.role === "assistant" || message.role === "user";
if (!canContainImage || message.image === undefined) {
return false;
}
const isMalformed = message.image.format === undefined || message.image.bytes === undefined;
if (isMalformed) {
return false;
}
return true;
}
/*
----------------------------
AGUI Message -> GQL Message
----------------------------
*/
export function aguiToGQL(
messages: agui.Message[] | agui.Message,
actions?: Record<string, any>,
coAgentStateRenders?: Record<string, any>,
): gql.Message[] {
const gqlMessages: gql.Message[] = [];
messages = Array.isArray(messages) ? messages : [messages];
// Track tool call names by their IDs for use in result messages
const toolCallNames: Record<string, string> = {};
for (const message of messages) {
// Agent state message support
if (isAgentStateMessage(message)) {
const agentName = extractAgentName(message);
const state = "state" in message && message.state ? message.state : {};
gqlMessages.push(
new gql.AgentStateMessage({
id: message.id,
agentName,
state,
role: gql.Role.Assistant,
}),
);
// Optionally preserve render function
if ("generativeUI" in message && message.generativeUI && coAgentStateRenders) {
coAgentStateRenders[agentName] = {
name: agentName,
render: message.generativeUI,
};
}
continue;
}
if (hasImageProperty(message)) {
gqlMessages.push(aguiMessageWithImageToGQLMessage(message));
continue;
}
// Action execution message support
if (message.role === "assistant" && message.toolCalls) {
gqlMessages.push(aguiTextMessageToGQLMessage(message));
for (const toolCall of message.toolCalls) {
// Track the tool call name by its ID
toolCallNames[toolCall.id] = toolCall.function.name;
const actionExecMsg = aguiToolCallToGQLActionExecution(toolCall, message.id);
// Preserve render function in actions context
if ("generativeUI" in message && message.generativeUI && actions) {
const actionName = toolCall.function.name;
// Check for specific action first, then wild card action
const specificAction = Object.values(actions).find(
(action: any) => action.name === actionName,
);
const wildcardAction = Object.values(actions).find((action: any) => action.name === "*");
// Assign render function to the matching action (specific takes priority)
if (specificAction) {
specificAction.render = message.generativeUI;
} else if (wildcardAction) {
wildcardAction.render = message.generativeUI;
}
}
gqlMessages.push(actionExecMsg);
}
continue;
}
// Regular text messages
if (
message.role === "developer" ||
message.role === "system" ||
message.role === "assistant" ||
message.role === "user"
) {
gqlMessages.push(aguiTextMessageToGQLMessage(message));
continue;
}
// Tool result message
if (message.role === "tool") {
gqlMessages.push(aguiToolMessageToGQLResultMessage(message, toolCallNames));
continue;
}
throw new Error(
`Unknown message role: "${(message as any).role}" in message with id: ${(message as any).id}`,
);
}
return gqlMessages;
}
export function aguiTextMessageToGQLMessage(message: agui.Message): gql.TextMessage {
if (
message.role !== "developer" &&
message.role !== "system" &&
message.role !== "assistant" &&
message.role !== "user"
) {
throw new Error(`Cannot convert message with role ${message.role} to TextMessage`);
}
let roleValue: MessageRole;
if (message.role === "developer") {
roleValue = gql.Role.Developer;
} else if (message.role === "system") {
roleValue = gql.Role.System;
} else if (message.role === "assistant") {
roleValue = gql.Role.Assistant;
} else {
roleValue = gql.Role.User;
}
return new gql.TextMessage({
id: message.id,
content: message.content || "",
role: roleValue,
});
}
export function aguiToolCallToGQLActionExecution(
toolCall: agui.ToolCall,
parentMessageId: string,
): gql.ActionExecutionMessage {
if (toolCall.type !== "function") {
throw new Error(`Unsupported tool call type: ${toolCall.type}`);
}
// Handle arguments - they should be a JSON string in AGUI format,
// but we need to convert them to an object for GQL format
let argumentsObj: any;
if (typeof toolCall.function.arguments === "string") {
// Expected case: arguments is a JSON string
try {
argumentsObj = JSON.parse(toolCall.function.arguments);
} catch (error) {
console.warn(`Failed to parse tool call arguments for ${toolCall.function.name}:`, error);
// Provide fallback empty object to prevent application crash
argumentsObj = {};
}
} else if (
typeof toolCall.function.arguments === "object" &&
toolCall.function.arguments !== null
) {
// Backward compatibility: arguments is already an object
argumentsObj = toolCall.function.arguments;
} else {
// Fallback for undefined, null, or other types
console.warn(
`Invalid tool call arguments type for ${toolCall.function.name}:`,
typeof toolCall.function.arguments,
);
argumentsObj = {};
}
// Always include name and arguments
return new gql.ActionExecutionMessage({
id: toolCall.id,
name: toolCall.function.name,
arguments: argumentsObj,
parentMessageId: parentMessageId,
});
}
export function aguiToolMessageToGQLResultMessage(
message: agui.Message,
toolCallNames: Record<string, string>,
): gql.ResultMessage {
if (message.role !== "tool") {
throw new Error(`Cannot convert message with role ${message.role} to ResultMessage`);
}
if (!message.toolCallId) {
throw new Error("Tool message must have a toolCallId");
}
const actionName = toolCallNames[message.toolCallId] || "unknown";
// Handle result content - it could be a string or an object that needs serialization
let resultContent: string;
const messageContent = message.content || "";
if (typeof messageContent === "string") {
// Expected case: content is already a string
resultContent = messageContent;
} else if (typeof messageContent === "object" && messageContent !== null) {
// Handle case where content is an object that needs to be serialized
try {
resultContent = JSON.stringify(messageContent);
} catch (error) {
console.warn(`Failed to stringify tool result for ${actionName}:`, error);
resultContent = String(messageContent);
}
} else {
// Handle other types (number, boolean, etc.)
resultContent = String(messageContent);
}
return new gql.ResultMessage({
id: message.id,
result: resultContent,
actionExecutionId: message.toolCallId,
actionName: message.toolName || actionName,
});
}
// New function to handle AGUI messages with render functions
export function aguiMessageWithRenderToGQL(
message: agui.Message,
actions?: Record<string, any>,
coAgentStateRenders?: Record<string, any>,
): gql.Message[] {
// Handle the special case: assistant messages with render function but no tool calls
if (
message.role === "assistant" &&
"generativeUI" in message &&
message.generativeUI &&
!message.toolCalls
) {
const gqlMessages: gql.Message[] = [];
gqlMessages.push(
new gql.AgentStateMessage({
id: message.id,
agentName: "unknown",
state: {},
role: gql.Role.Assistant,
}),
);
if (coAgentStateRenders) {
coAgentStateRenders.unknown = {
name: "unknown",
render: message.generativeUI,
};
}
return gqlMessages;
}
// For all other cases, delegate to aguiToGQL
return aguiToGQL([message], actions, coAgentStateRenders);
}
export function aguiMessageWithImageToGQLMessage(message: agui.Message): gql.ImageMessage {
if (!hasImageProperty(message)) {
throw new Error(`Cannot convert message to ImageMessage: missing format or bytes`);
}
let roleValue: MessageRole;
if (message.role === "assistant") {
roleValue = gql.Role.Assistant;
} else {
roleValue = gql.Role.User;
}
if (message.role !== "assistant" && message.role !== "user") {
throw new Error(`Cannot convert message with role ${message.role} to ImageMessage`);
}
return new gql.ImageMessage({
id: message.id,
format: message.image!.format,
bytes: message.image!.bytes,
role: roleValue,
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,278 @@
import * as gql from "../client";
import agui from "@copilotkit/shared";
import { MessageStatusCode } from "../graphql/@generated/graphql";
// Define valid image formats based on the supported formats in the codebase
const VALID_IMAGE_FORMATS = ["jpeg", "png", "webp", "gif"] as const;
type ValidImageFormat = (typeof VALID_IMAGE_FORMATS)[number];
// Validation function for image format
function validateImageFormat(format: string): format is ValidImageFormat {
return VALID_IMAGE_FORMATS.includes(format as ValidImageFormat);
}
/*
----------------------------
GQL Message -> AGUI Message
----------------------------
*/
export function gqlToAGUI(
messages: gql.Message[] | gql.Message,
actions?: Record<string, any>,
coAgentStateRenders?: Record<string, any>,
): agui.Message[] {
let aguiMessages: agui.Message[] = [];
messages = Array.isArray(messages) ? messages : [messages];
// Create a map of action execution ID to result for completed actions
const actionResults = new Map<string, string>();
for (const message of messages) {
if (message.isResultMessage()) {
actionResults.set(message.actionExecutionId, message.result);
}
}
for (const message of messages) {
if (message.isTextMessage()) {
aguiMessages.push(gqlTextMessageToAGUIMessage(message));
} else if (message.isResultMessage()) {
aguiMessages.push(gqlResultMessageToAGUIMessage(message));
} else if (message.isActionExecutionMessage()) {
aguiMessages.push(gqlActionExecutionMessageToAGUIMessage(message, actions, actionResults));
} else if (message.isAgentStateMessage()) {
aguiMessages.push(gqlAgentStateMessageToAGUIMessage(message, coAgentStateRenders));
} else if (message.isImageMessage()) {
aguiMessages.push(gqlImageMessageToAGUIMessage(message));
} else {
throw new Error("Unknown message type");
}
}
return aguiMessages;
}
export function gqlActionExecutionMessageToAGUIMessage(
message: gql.ActionExecutionMessage,
actions?: Record<string, any>,
actionResults?: Map<string, string>,
): agui.Message {
// Check if we have actions and if there's a specific action or wild card action
const hasSpecificAction =
actions && Object.values(actions).some((action: any) => action.name === message.name);
const hasWildcardAction =
actions && Object.values(actions).some((action: any) => action.name === "*");
if (!actions || (!hasSpecificAction && !hasWildcardAction)) {
return {
id: message.id,
role: "assistant",
toolCalls: [actionExecutionMessageToAGUIMessage(message)],
name: message.name,
};
}
// Find the specific action first, then fall back to wild card action
const action =
Object.values(actions).find((action: any) => action.name === message.name) ||
Object.values(actions).find((action: any) => action.name === "*");
// Create render function wrapper that provides proper props
const createRenderWrapper = (originalRender: any) => {
if (!originalRender) return undefined;
return (props?: any) => {
// Determine the correct status based on the same logic as RenderActionExecutionMessage
let actionResult: any = actionResults?.get(message.id);
let status: "inProgress" | "executing" | "complete" = "inProgress";
if (actionResult !== undefined) {
status = "complete";
} else if (message.status?.code !== MessageStatusCode.Pending) {
status = "executing";
}
// if props.result is a string, parse it as JSON but don't throw an error if it's not valid JSON
if (typeof props?.result === "string") {
try {
props.result = JSON.parse(props.result);
} catch (e) {
/* do nothing */
}
}
// if actionResult is a string, parse it as JSON but don't throw an error if it's not valid JSON
if (typeof actionResult === "string") {
try {
actionResult = JSON.parse(actionResult);
} catch (e) {
/* do nothing */
}
}
// Base props that all actions receive
const baseProps = {
status: props?.status || status,
args: message.arguments || {},
result: props?.result || actionResult || undefined,
messageId: message.id,
};
// Add properties based on action type
if (action.name === "*") {
// Wildcard actions get the tool name; ensure it cannot be overridden by incoming props
return originalRender({
...baseProps,
...props,
name: message.name,
});
} else {
// Regular actions get respond (defaulting to a no-op if not provided)
const respond = props?.respond ?? (() => {});
return originalRender({
...baseProps,
...props,
respond,
});
}
};
};
return {
id: message.id,
role: "assistant",
content: "",
toolCalls: [actionExecutionMessageToAGUIMessage(message)],
generativeUI: createRenderWrapper(action.render),
name: message.name,
} as agui.AIMessage;
}
function gqlAgentStateMessageToAGUIMessage(
message: gql.AgentStateMessage,
coAgentStateRenders?: Record<string, any>,
): agui.Message {
if (
coAgentStateRenders &&
Object.values(coAgentStateRenders).some((render: any) => render.name === message.agentName)
) {
const render = Object.values(coAgentStateRenders).find(
(render: any) => render.name === message.agentName,
);
// Create render function wrapper that provides proper props
const createRenderWrapper = (originalRender: any) => {
if (!originalRender) return undefined;
return (props?: any) => {
// Determine the correct status based on the same logic as RenderActionExecutionMessage
const state = message.state;
// Provide the full props structure that the render function expects
const renderProps = {
state: state,
};
return originalRender(renderProps);
};
};
return {
id: message.id,
role: "assistant",
generativeUI: createRenderWrapper(render.render),
agentName: message.agentName,
state: message.state,
};
}
return {
id: message.id,
role: "assistant",
agentName: message.agentName,
state: message.state,
};
}
function actionExecutionMessageToAGUIMessage(
actionExecutionMessage: gql.ActionExecutionMessage,
): agui.ToolCall {
return {
id: actionExecutionMessage.id,
function: {
name: actionExecutionMessage.name,
arguments: JSON.stringify(actionExecutionMessage.arguments),
},
type: "function",
};
}
export function gqlTextMessageToAGUIMessage(message: gql.TextMessage): agui.Message {
switch (message.role) {
case gql.Role.Developer:
return {
id: message.id,
role: "developer",
content: message.content,
};
case gql.Role.System:
return {
id: message.id,
role: "system",
content: message.content,
};
case gql.Role.Assistant:
return {
id: message.id,
role: "assistant",
content: message.content,
};
case gql.Role.User:
return {
id: message.id,
role: "user",
content: message.content,
};
default:
throw new Error("Unknown message role");
}
}
export function gqlResultMessageToAGUIMessage(message: gql.ResultMessage): agui.Message {
return {
id: message.id,
role: "tool",
content: message.result,
toolCallId: message.actionExecutionId,
toolName: message.actionName,
};
}
export function gqlImageMessageToAGUIMessage(message: gql.ImageMessage): agui.Message {
// Validate image format
if (!validateImageFormat(message.format)) {
throw new Error(
`Invalid image format: ${message.format}. Supported formats are: ${VALID_IMAGE_FORMATS.join(", ")}`,
);
}
// Validate that bytes is a non-empty string
if (!message.bytes || typeof message.bytes !== "string" || message.bytes.trim() === "") {
throw new Error("Image bytes must be a non-empty string");
}
// Determine the role based on the message role
const role = message.role === gql.Role.Assistant ? "assistant" : "user";
// Create the image message with proper typing
const imageMessage: agui.Message = {
id: message.id,
role,
content: "",
image: {
format: message.format,
bytes: message.bytes,
},
};
return imageMessage;
}

View File

@@ -0,0 +1,2 @@
export * from "./agui-to-gql";
export * from "./gql-to-agui";

View File

@@ -0,0 +1,526 @@
import { describe, test, expect, vi } from "vitest";
import * as gql from "../client";
import agui from "@copilotkit/shared";
import { aguiToGQL } from "./agui-to-gql";
import { gqlToAGUI } from "./gql-to-agui";
// Helper to strip functions for deep equality
function stripFunctions(obj: any): any {
if (typeof obj === "function") return undefined;
if (Array.isArray(obj)) return obj.map(stripFunctions);
if (obj && typeof obj === "object") {
const out: any = {};
for (const k in obj) {
if (typeof obj[k] !== "function") {
out[k] = stripFunctions(obj[k]);
}
}
return out;
}
return obj;
}
describe("roundtrip message conversion", () => {
test("text message AGUI -> GQL -> AGUI", () => {
const aguiMsg: agui.Message = {
id: "user-1",
role: "user",
content: "Hello!",
};
const gqlMsgs = aguiToGQL(aguiMsg);
const aguiMsgs2 = gqlToAGUI(gqlMsgs);
expect(stripFunctions(aguiMsgs2[0])).toEqual(stripFunctions(aguiMsg));
});
test("text message GQL -> AGUI -> GQL", () => {
const gqlMsg = new gql.TextMessage({
id: "assistant-1",
content: "Hi!",
role: gql.Role.Assistant,
});
const aguiMsgs = gqlToAGUI(gqlMsg);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
// Should be equivalent in content, id, and role
expect(gqlMsgs2[0].id).toBe(gqlMsg.id);
expect((gqlMsgs2[0] as any).content).toBe(gqlMsg.content);
expect((gqlMsgs2[0] as any).role).toBe(gqlMsg.role);
});
test("tool message AGUI -> GQL -> AGUI", () => {
const aguiMsg: agui.Message = {
id: "tool-1",
role: "tool",
content: "Tool result",
toolCallId: "tool-call-1",
toolName: "testAction",
};
const gqlMsgs = aguiToGQL(aguiMsg);
const aguiMsgs2 = gqlToAGUI(gqlMsgs);
expect(stripFunctions(aguiMsgs2[0])).toEqual(stripFunctions(aguiMsg));
});
test("tool message GQL -> AGUI -> GQL", () => {
const gqlMsg = new gql.ResultMessage({
id: "tool-1",
result: "Tool result",
actionExecutionId: "tool-call-1",
actionName: "testAction",
});
const aguiMsgs = gqlToAGUI(gqlMsg);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
expect(gqlMsgs2[0].id).toBe(gqlMsg.id);
expect((gqlMsgs2[0] as any).result).toBe(gqlMsg.result);
expect((gqlMsgs2[0] as any).actionExecutionId).toBe(gqlMsg.actionExecutionId);
});
test("action execution AGUI -> GQL -> AGUI", () => {
const aguiMsg: agui.Message = {
id: "assistant-1",
role: "assistant",
content: "Running action",
toolCalls: [
{
id: "tool-call-1",
type: "function",
function: {
name: "doSomething",
arguments: JSON.stringify({ foo: "bar" }),
},
},
],
};
const gqlMsgs = aguiToGQL(aguiMsg);
const aguiMsgs2 = gqlToAGUI(gqlMsgs);
// Should have an assistant message and an action execution message
expect(aguiMsgs2[0].role).toBe("assistant");
expect(aguiMsgs2[1].role).toBe("assistant");
// Only check toolCalls if present
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe("doSomething");
}
});
test("action execution GQL -> AGUI -> GQL", () => {
const actionExecMsg = new gql.ActionExecutionMessage({
id: "tool-call-1",
name: "doSomething",
arguments: { foo: "bar" },
parentMessageId: "assistant-1",
});
const aguiMsgs = gqlToAGUI([actionExecMsg]);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
// The ActionExecutionMessage is at index 1, not index 0
expect(gqlMsgs2[1].id).toBe("tool-call-1");
// The name should be extracted from the toolCall function name
expect((gqlMsgs2[1] as any).name).toBe("doSomething");
expect((gqlMsgs2[1] as any).arguments).toEqual({ foo: "bar" });
});
test("agent state GQL -> AGUI -> GQL", () => {
const agentStateMsg = new gql.AgentStateMessage({
id: "agent-state-1",
agentName: "testAgent",
state: { status: "running" },
role: gql.Role.Assistant,
});
const aguiMsgs = gqlToAGUI([agentStateMsg]);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
expect(gqlMsgs2[0].id).toBe("agent-state-1");
// The agentName should be preserved in the roundtrip
expect((gqlMsgs2[0] as any).agentName).toBe("testAgent");
});
test("action execution with render function roundtrip", () => {
const mockRender = vi.fn();
const aguiMsg: agui.Message = {
id: "assistant-1",
role: "assistant",
content: "Running action",
toolCalls: [
{
id: "tool-call-1",
type: "function",
function: {
name: "doSomething",
arguments: JSON.stringify({ foo: "bar" }),
},
},
],
generativeUI: mockRender,
};
const actions: Record<string, any> = { doSomething: { name: "doSomething" } };
const gqlMsgs = aguiToGQL(aguiMsg, actions);
const aguiMsgs2 = gqlToAGUI(gqlMsgs, actions);
// The render function should be preserved in actions context
expect(typeof actions.doSomething.render).toBe("function");
// The roundtripped message should have the same tool call
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe("doSomething");
}
});
test("image message GQL -> AGUI -> GQL", () => {
const gqlMsg = new gql.ImageMessage({
id: "img-1",
format: "jpeg",
bytes: "somebase64string",
role: gql.Role.User,
});
const aguiMsgs = gqlToAGUI(gqlMsg);
const gqlMsgs2 = aguiToGQL(aguiMsgs);
expect(gqlMsgs2[0].id).toBe(gqlMsg.id);
expect((gqlMsgs2[0] as any).format).toBe(gqlMsg.format);
expect((gqlMsgs2[0] as any).bytes).toBe(gqlMsg.bytes);
expect((gqlMsgs2[0] as any).role).toBe(gqlMsg.role);
});
test("image message AGUI -> GQL -> AGUI (assistant and user)", () => {
// Assistant image message
const aguiAssistantImageMsg: agui.Message = {
id: "img-assistant-1",
role: "assistant",
image: {
format: "jpeg",
bytes: "assistantbase64data",
},
content: "", // required for type
};
const gqlAssistantMsgs = aguiToGQL(aguiAssistantImageMsg);
const aguiAssistantMsgs2 = gqlToAGUI(gqlAssistantMsgs);
expect(aguiAssistantMsgs2[0].id).toBe(aguiAssistantImageMsg.id);
expect(aguiAssistantMsgs2[0].role).toBe("assistant");
expect((aguiAssistantMsgs2[0] as any).image.format).toBe("jpeg");
expect((aguiAssistantMsgs2[0] as any).image.bytes).toBe("assistantbase64data");
// User image message
const aguiUserImageMsg: agui.Message = {
id: "img-user-1",
role: "user",
image: {
format: "png",
bytes: "userbase64data",
},
content: "", // required for type
};
const gqlUserMsgs = aguiToGQL(aguiUserImageMsg);
const aguiUserMsgs2 = gqlToAGUI(gqlUserMsgs);
expect(aguiUserMsgs2[0].id).toBe(aguiUserImageMsg.id);
expect(aguiUserMsgs2[0].role).toBe("user");
expect((aguiUserMsgs2[0] as any).image.format).toBe("png");
expect((aguiUserMsgs2[0] as any).image.bytes).toBe("userbase64data");
});
test("wild card action roundtrip conversion", () => {
const mockRender = vi.fn((props) => `Wildcard rendered: ${props.args.test}`);
const aguiMsg: agui.Message = {
id: "assistant-wildcard-1",
role: "assistant",
content: "Running wild card action",
toolCalls: [
{
id: "tool-call-wildcard-1",
type: "function",
function: {
name: "unknownAction",
arguments: JSON.stringify({ test: "wildcard-value" }),
},
},
],
generativeUI: mockRender,
};
const actions: Record<string, any> = {
"*": { name: "*" },
};
// AGUI -> GQL -> AGUI roundtrip
const gqlMsgs = aguiToGQL(aguiMsg, actions);
const aguiMsgs2 = gqlToAGUI(gqlMsgs, actions);
// Verify the wild card action preserved the render function
expect(typeof actions["*"].render).toBe("function");
expect(actions["*"].render).toBe(mockRender);
// Verify the roundtripped message structure
expect(aguiMsgs2).toHaveLength(2);
expect(aguiMsgs2[0].role).toBe("assistant");
expect(aguiMsgs2[1].role).toBe("assistant");
// Check that the tool call is preserved
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe("unknownAction");
expect((aguiMsgs2[1] as any).toolCalls[0].function.arguments).toBe(
'{"test":"wildcard-value"}',
);
}
});
test("wild card action with specific action priority roundtrip", () => {
const mockRender = vi.fn((props) => `Specific action rendered: ${props.args.test}`);
const aguiMsg: agui.Message = {
id: "assistant-priority-1",
role: "assistant",
content: "Running specific action",
toolCalls: [
{
id: "tool-call-priority-1",
type: "function",
function: {
name: "specificAction",
arguments: JSON.stringify({ test: "specific-value" }),
},
},
],
generativeUI: mockRender,
};
const actions: Record<string, any> = {
specificAction: { name: "specificAction" },
"*": { name: "*" },
};
// AGUI -> GQL -> AGUI roundtrip
const gqlMsgs = aguiToGQL(aguiMsg, actions);
const aguiMsgs2 = gqlToAGUI(gqlMsgs, actions);
// Verify the specific action preserved the render function (not wild card)
expect(typeof actions.specificAction.render).toBe("function");
expect(actions.specificAction.render).toBe(mockRender);
expect(actions["*"].render).toBeUndefined();
// Verify the roundtripped message structure
expect(aguiMsgs2).toHaveLength(2);
expect(aguiMsgs2[0].role).toBe("assistant");
expect(aguiMsgs2[1].role).toBe("assistant");
// Check that the tool call is preserved
if ("toolCalls" in aguiMsgs2[1]) {
expect((aguiMsgs2[1] as any).toolCalls[0].function.name).toBe("specificAction");
expect((aguiMsgs2[1] as any).toolCalls[0].function.arguments).toBe(
'{"test":"specific-value"}',
);
}
});
test("wild card action GQL -> AGUI -> GQL roundtrip", () => {
const actionExecMsg = new gql.ActionExecutionMessage({
id: "wildcard-action-1",
name: "unknownAction",
arguments: { test: "wildcard-gql-value" },
parentMessageId: "assistant-1",
});
const actions: Record<string, any> = {
"*": {
name: "*",
render: vi.fn((props) => `GQL wildcard rendered: ${props.args.test}`),
},
};
// GQL -> AGUI -> GQL roundtrip
const aguiMsgs = gqlToAGUI([actionExecMsg], actions);
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// When converting ActionExecutionMessage to AGUI and back, we get:
// 1. A TextMessage (assistant message with toolCalls)
// 2. An ActionExecutionMessage (the tool call itself)
expect(gqlMsgs2).toHaveLength(2);
expect(gqlMsgs2[0].id).toBe("wildcard-action-1");
expect((gqlMsgs2[0] as any).role).toBe(gql.Role.Assistant);
expect(gqlMsgs2[1].id).toBe("wildcard-action-1");
expect((gqlMsgs2[1] as any).name).toBe("unknownAction");
expect((gqlMsgs2[1] as any).arguments).toEqual({ test: "wildcard-gql-value" });
});
test("roundtrip conversion with result parsing edge cases", () => {
// Test with a tool result that contains a JSON string
const toolResultMsg: agui.Message = {
id: "tool-result-json",
role: "tool",
content: '{"status": "success", "data": {"value": 42}}',
toolCallId: "tool-call-json",
toolName: "jsonAction",
};
// Convert AGUI -> GQL -> AGUI
const gqlMsgs = aguiToGQL(toolResultMsg);
const aguiMsgs = gqlToAGUI(gqlMsgs);
expect(gqlMsgs).toHaveLength(1);
expect(gqlMsgs[0]).toBeInstanceOf(gql.ResultMessage);
expect((gqlMsgs[0] as any).result).toBe('{"status": "success", "data": {"value": 42}}');
expect(aguiMsgs).toHaveLength(1);
expect(aguiMsgs[0].role).toBe("tool");
expect(aguiMsgs[0].content).toBe('{"status": "success", "data": {"value": 42}}');
});
test("roundtrip conversion with object content in tool results", () => {
// Test with a tool result that has object content (edge case)
const toolResultMsg: agui.Message = {
id: "tool-result-object",
role: "tool",
content: { status: "success", data: { value: 42 } } as any,
toolCallId: "tool-call-object",
toolName: "objectAction",
};
// Convert AGUI -> GQL -> AGUI
const gqlMsgs = aguiToGQL(toolResultMsg);
const aguiMsgs = gqlToAGUI(gqlMsgs);
expect(gqlMsgs).toHaveLength(1);
expect(gqlMsgs[0]).toBeInstanceOf(gql.ResultMessage);
expect((gqlMsgs[0] as any).result).toBe('{"status":"success","data":{"value":42}}');
expect(aguiMsgs).toHaveLength(1);
expect(aguiMsgs[0].role).toBe("tool");
expect(aguiMsgs[0].content).toBe('{"status":"success","data":{"value":42}}');
});
test("roundtrip conversion with action execution and result parsing", () => {
const mockRender = vi.fn((props) => `Rendered: ${JSON.stringify(props.result)}`);
// Create action execution message
const actionExecMsg = new gql.ActionExecutionMessage({
id: "action-with-result",
name: "testAction",
arguments: { input: "test-value" },
parentMessageId: "parent-result",
});
// Create result message
const resultMsg = new gql.ResultMessage({
id: "result-with-json",
result: '{"output": "processed", "count": 5}',
actionExecutionId: "action-with-result",
actionName: "testAction",
});
const actions = {
testAction: {
name: "testAction",
render: mockRender,
},
};
// Convert GQL -> AGUI
const aguiMsgs = gqlToAGUI([actionExecMsg, resultMsg], actions);
// The action execution should have a generativeUI function that parses string results
expect(aguiMsgs).toHaveLength(2);
expect(aguiMsgs[0].role).toBe("assistant");
expect("generativeUI" in aguiMsgs[0]).toBe(true);
expect(aguiMsgs[1].role).toBe("tool");
expect(aguiMsgs[1].content).toBe('{"output": "processed", "count": 5}');
// Test that the render function receives parsed results
if ("generativeUI" in aguiMsgs[0] && aguiMsgs[0].generativeUI) {
aguiMsgs[0].generativeUI({ result: '{"parsed": true}' });
expect(mockRender).toHaveBeenCalledWith(
expect.objectContaining({
result: { parsed: true }, // Should be parsed from string
}),
);
}
// Convert back AGUI -> GQL
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// Should have 3 messages: TextMessage, ActionExecutionMessage, ResultMessage
expect(gqlMsgs2).toHaveLength(3);
expect(gqlMsgs2[0]).toBeInstanceOf(gql.TextMessage);
expect(gqlMsgs2[1]).toBeInstanceOf(gql.ActionExecutionMessage);
expect(gqlMsgs2[2]).toBeInstanceOf(gql.ResultMessage);
// Check that arguments roundtripped correctly
expect((gqlMsgs2[1] as any).arguments).toEqual({ input: "test-value" });
expect((gqlMsgs2[2] as any).result).toBe('{"output": "processed", "count": 5}');
});
test("roundtrip conversion verifies correct property distribution for regular actions", () => {
const mockRender = vi.fn((props) => `Regular action: ${JSON.stringify(props.args)}`);
const actionExecMsg = new gql.ActionExecutionMessage({
id: "regular-action-test",
name: "regularAction",
arguments: { test: "regular-value" },
parentMessageId: "parent-regular",
});
const actions = {
regularAction: {
name: "regularAction",
render: mockRender,
},
};
// GQL -> AGUI -> GQL roundtrip
const aguiMsgs = gqlToAGUI([actionExecMsg], actions);
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// Verify the roundtrip preserved the action
expect(gqlMsgs2).toHaveLength(2);
expect(gqlMsgs2[1]).toBeInstanceOf(gql.ActionExecutionMessage);
expect((gqlMsgs2[1] as any).name).toBe("regularAction");
// Test that regular actions do NOT receive the name property in render props
if ("generativeUI" in aguiMsgs[0] && aguiMsgs[0].generativeUI) {
aguiMsgs[0].generativeUI();
expect(mockRender).toHaveBeenCalledWith(
expect.objectContaining({
args: { test: "regular-value" },
// name property should NOT be present for regular actions
}),
);
// Verify name property is NOT present
const callArgs = mockRender.mock.calls[0][0];
expect(callArgs).not.toHaveProperty("name");
}
});
test("roundtrip conversion verifies correct property distribution for wildcard actions", () => {
const mockRender = vi.fn(
(props) => `Wildcard action: ${props.name} with ${JSON.stringify(props.args)}`,
);
const actionExecMsg = new gql.ActionExecutionMessage({
id: "wildcard-action-test",
name: "unknownAction",
arguments: { test: "wildcard-value" },
parentMessageId: "parent-wildcard",
});
const actions = {
"*": {
name: "*",
render: mockRender,
},
};
// GQL -> AGUI -> GQL roundtrip
const aguiMsgs = gqlToAGUI([actionExecMsg], actions);
const gqlMsgs2 = aguiToGQL(aguiMsgs, actions);
// Verify the roundtrip preserved the action
expect(gqlMsgs2).toHaveLength(2);
expect(gqlMsgs2[1]).toBeInstanceOf(gql.ActionExecutionMessage);
expect((gqlMsgs2[1] as any).name).toBe("unknownAction");
// Test that wildcard actions DO receive the name property in render props
if ("generativeUI" in aguiMsgs[0] && aguiMsgs[0].generativeUI) {
aguiMsgs[0].generativeUI();
expect(mockRender).toHaveBeenCalledWith(
expect.objectContaining({
args: { test: "wildcard-value" },
name: "unknownAction", // name property SHOULD be present for wildcard actions
}),
);
// Verify name property IS present
const callArgs = mockRender.mock.calls[0][0];
expect(callArgs).toHaveProperty("name", "unknownAction");
}
});
});