diff --git a/package.json b/package.json index 4d62fb07..6b239d6c 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,10 @@ "test:e2e:http:client": "jest --runInBand --forceExit --testMatch [ '**/test/e2e/http/(client).test.ts' ]", "test:e2e:http:server": "jest --runInBand --forceExit --testMatch [ '**/test/e2e/http/(server).test.ts' ]", "test:e2e:http:actors": "jest --runInBand --forceExit --testMatch [ '**/test/e2e/http/actors.test.ts' ]", - "test:e2e:common": "npm run test:e2e:common:client && npm run test:e2e:common:server", + "test:e2e:common": "npm run test:e2e:common:client && npm run test:e2e:common:server && npm run test:e2e:common:conversation", "test:e2e:common:client": "jest --runInBand --forceExit --testMatch [ '**/test/e2e/common/client.test.ts' ]", "test:e2e:common:server": "jest --runInBand --forceExit --testMatch [ '**/test/e2e/common/server.test.ts' ]", + "test:e2e:common:conversation": "jest --runInBand --forceExit --testMatch [ '**/test/e2e/common/conversation.test.ts' ]", "test:e2e:workflow": "jest --runInBand --forceExit --testMatch [ '**/test/e2e/workflow/workflow.test.ts' ]", "test:e2e:workflow:internal": "jest test/e2e/workflow --runInBand --forceExit", "test:e2e:workflow:durabletask": "node scripts/test-e2e-workflow.js", diff --git a/src/implementation/Client/DaprClient.ts b/src/implementation/Client/DaprClient.ts index 3a3b1ad6..6e810af4 100644 --- a/src/implementation/Client/DaprClient.ts +++ b/src/implementation/Client/DaprClient.ts @@ -26,6 +26,7 @@ import IClientSecret from "../../interfaces/Client/IClientSecret"; import IClientSidecar from "../../interfaces/Client/IClientSidecar"; import IClientState from "../../interfaces/Client/IClientState"; import IClientWorkflow from "../../interfaces/Client/IClientWorkflow"; +import IClientConversation from "../../interfaces/Client/IClientConversation"; import GRPCClient from "./GRPCClient/GRPCClient"; import GRPCClientActor from "./GRPCClient/actor"; @@ -41,6 +42,7 @@ import GRPCClientSecret from "./GRPCClient/secret"; import GRPCClientSidecar from "./GRPCClient/sidecar"; import GRPCClientState from "./GRPCClient/state"; import GRPCClientWorkflow from "./GRPCClient/workflow"; +import GRPCClientConversation from "./GRPCClient/conversation"; import HTTPClient from "./HTTPClient/HTTPClient"; import HTTPClientActor from "./HTTPClient/actor"; @@ -57,6 +59,7 @@ import HTTPClientSecret from "./HTTPClient/secret"; import HTTPClientSidecar from "./HTTPClient/sidecar"; import HTTPClientState from "./HTTPClient/state"; import HTTPClientWorkflow from "./HTTPClient/workflow"; +import HTTPClientConversation from "./HTTPClient/conversation"; import CommunicationProtocolEnum from "../../enum/CommunicationProtocol.enum"; import { DaprClientOptions } from "../../types/DaprClientOptions"; @@ -83,6 +86,7 @@ export default class DaprClient { readonly sidecar: IClientSidecar; readonly state: IClientState; readonly workflow: IClientWorkflow; + readonly conversation: IClientConversation; private readonly logger: Logger; @@ -119,6 +123,7 @@ export default class DaprClient { this.crypto = new GRPCClientCrypto(client); this.actor = new GRPCClientActor(client); // we use an abstractor here since we interface through a builder with the Actor Runtime this.workflow = new GRPCClientWorkflow(client); + this.conversation = new GRPCClientConversation(client); break; } case CommunicationProtocolEnum.HTTP: @@ -140,6 +145,7 @@ export default class DaprClient { this.sidecar = new HTTPClientSidecar(client); this.state = new HTTPClientState(client); this.workflow = new HTTPClientWorkflow(client); + this.conversation = new HTTPClientConversation(client); break; } } diff --git a/src/implementation/Client/GRPCClient/conversation.ts b/src/implementation/Client/GRPCClient/conversation.ts new file mode 100644 index 00000000..f2d9ac7a --- /dev/null +++ b/src/implementation/Client/GRPCClient/conversation.ts @@ -0,0 +1,210 @@ +/* +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { create } from "@bufbuild/protobuf"; +import type { JsonObject } from "@bufbuild/protobuf"; +import GRPCClient from "./GRPCClient"; +import IClientConversation from "../../../interfaces/Client/IClientConversation"; +import { + ConversationRequestAlpha2Schema, + ConversationInputAlpha2Schema, + ConversationMessageSchema, + ConversationMessageContentSchema, + ConversationMessageOfDeveloperSchema, + ConversationMessageOfSystemSchema, + ConversationMessageOfUserSchema, + ConversationMessageOfAssistantSchema, + ConversationMessageOfToolSchema, + ConversationToolsSchema, + ConversationToolsFunctionSchema, + ConversationToolCallsSchema, + ConversationToolCallsOfFunctionSchema, +} from "../../../proto/dapr/proto/runtime/v1/dapr_pb"; +import type { + ConversationInputAlpha2, + ConversationMessage as ProtoConversationMessage, + ConversationTools as ProtoConversationTools, + ConversationResponseAlpha2, +} from "../../../proto/dapr/proto/runtime/v1/dapr_pb"; +import { + ConversationOptions, + ConversationResponse, + ConversationInput, + ConversationMessage, + ConversationTool, + ConversationResultChoice, + ConversationResult, + ConversationResultToolCall, +} from "../../../types/conversation/Conversation.type"; + +// https://docs.dapr.io/reference/api/conversation_api/ +export default class GRPCClientConversation implements IClientConversation { + client: GRPCClient; + + constructor(client: GRPCClient) { + this.client = client; + } + + async converse( + conversationComponentName: string, + inputs: ConversationInput[], + options?: ConversationOptions, + ): Promise { + const client = await this.client.getClient(); + + const protoInputs = inputs.map((input) => this.buildInput(input)); + const protoTools = (options?.tools ?? []).map((tool) => this.buildTool(tool)); + + const protoRequest = create(ConversationRequestAlpha2Schema, { + name: conversationComponentName, + contextId: options?.contextId, + inputs: protoInputs, + metadata: options?.metadata ?? {}, + scrubPii: options?.scrubPii, + temperature: options?.temperature, + tools: protoTools, + toolChoice: options?.toolChoice, + }); + + const res: ConversationResponseAlpha2 = await client.converseAlpha2(protoRequest); + + return this.mapResponse(res); + } + + private buildInput(input: ConversationInput): ConversationInputAlpha2 { + const messages = input.messages.map((msg) => this.buildMessage(msg)); + return create(ConversationInputAlpha2Schema, { + messages, + scrubPii: input.scrubPii, + }); + } + + private buildMessage(msg: ConversationMessage): ProtoConversationMessage { + const contentItems = msg.content.map((c) => create(ConversationMessageContentSchema, { text: c.text })); + + switch (msg.role) { + case "developer": + return create(ConversationMessageSchema, { + messageTypes: { + case: "ofDeveloper", + value: create(ConversationMessageOfDeveloperSchema, { + name: msg.name, + content: contentItems, + }), + }, + }); + case "system": + return create(ConversationMessageSchema, { + messageTypes: { + case: "ofSystem", + value: create(ConversationMessageOfSystemSchema, { + name: msg.name, + content: contentItems, + }), + }, + }); + case "user": + return create(ConversationMessageSchema, { + messageTypes: { + case: "ofUser", + value: create(ConversationMessageOfUserSchema, { + name: msg.name, + content: contentItems, + }), + }, + }); + case "assistant": { + const toolCalls = (msg.toolCalls ?? []).map((tc) => + create(ConversationToolCallsSchema, { + id: tc.id, + toolTypes: tc.function + ? { + case: "function", + value: create(ConversationToolCallsOfFunctionSchema, { + name: tc.function.name, + arguments: tc.function.arguments, + }), + } + : { case: undefined, value: undefined }, + }), + ); + return create(ConversationMessageSchema, { + messageTypes: { + case: "ofAssistant", + value: create(ConversationMessageOfAssistantSchema, { + name: msg.name, + content: contentItems, + toolCalls, + }), + }, + }); + } + case "tool": + return create(ConversationMessageSchema, { + messageTypes: { + case: "ofTool", + value: create(ConversationMessageOfToolSchema, { + toolId: msg.toolId, + name: msg.name, + content: contentItems, + }), + }, + }); + } + } + + private buildTool(tool: ConversationTool): ProtoConversationTools { + return create(ConversationToolsSchema, { + toolTypes: { + case: "function", + value: create(ConversationToolsFunctionSchema, { + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters as JsonObject | undefined, + }), + }, + }); + } + + private mapResponse(res: ConversationResponseAlpha2): ConversationResponse { + const outputs: ConversationResult[] = (res.outputs ?? []).map((output) => { + const choices: ConversationResultChoice[] = (output.choices ?? []).map((choice) => { + const toolCalls: ConversationResultToolCall[] | undefined = choice.message?.toolCalls?.map((tc) => ({ + id: tc.id, + function: + tc.toolTypes.case === "function" + ? { name: tc.toolTypes.value.name, arguments: tc.toolTypes.value.arguments } + : undefined, + })); + + return { + finishReason: choice.finishReason, + index: Number(choice.index), + message: choice.message + ? { + content: choice.message.content, + toolCalls: toolCalls?.length ? toolCalls : undefined, + } + : undefined, + }; + }); + + return { choices }; + }); + + return { + contextId: res.contextId, + outputs, + }; + } +} diff --git a/src/implementation/Client/HTTPClient/conversation.ts b/src/implementation/Client/HTTPClient/conversation.ts new file mode 100644 index 00000000..dffcedf2 --- /dev/null +++ b/src/implementation/Client/HTTPClient/conversation.ts @@ -0,0 +1,162 @@ +/* +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import HTTPClient from "./HTTPClient"; +import IClientConversation from "../../../interfaces/Client/IClientConversation"; +import { + ConversationOptions, + ConversationResponse, + ConversationInput, + ConversationResult, + ConversationResultChoice, + ConversationResultToolCall, +} from "../../../types/conversation/Conversation.type"; +import { Logger } from "../../../logger/Logger"; + +// https://docs.dapr.io/reference/api/conversation_api/ +export default class HTTPClientConversation implements IClientConversation { + private readonly client: HTTPClient; + private readonly logger: Logger; + + constructor(client: HTTPClient) { + this.client = client; + this.logger = new Logger("HTTPClient", "Conversation", client.options.logger); + } + + async converse( + conversationComponentName: string, + inputs: ConversationInput[], + options?: ConversationOptions, + ): Promise { + const body = this.buildRequestBody(inputs, options); + + try { + const result = await this.client.executeWithApiVersion( + "v1.0-alpha2", + `/conversation/${conversationComponentName}/converse`, + { + method: "POST", + body, + headers: { "Content-Type": "application/json" }, + }, + ); + + return this.mapResponse(result); + } catch (e: any) { + this.logger.error(`Error in conversation: ${e.message}`); + throw e; + } + } + + private buildRequestBody(inputs: ConversationInput[], options?: ConversationOptions): object { + const body: Record = {}; + + if (options?.contextId) { + body.contextID = options.contextId; + } + + body.inputs = inputs.map((input) => ({ + messages: input.messages.map((msg) => { + switch (msg.role) { + case "developer": + return { ofDeveloper: { name: msg.name, content: msg.content } }; + case "system": + return { ofSystem: { name: msg.name, content: msg.content } }; + case "user": + return { ofUser: { name: msg.name, content: msg.content } }; + case "assistant": + return { + ofAssistant: { + name: msg.name, + content: msg.content, + toolCalls: msg.toolCalls?.map((tc) => ({ + id: tc.id, + function: tc.function, + })), + }, + }; + case "tool": + return { ofTool: { toolId: msg.toolId, name: msg.name, content: msg.content } }; + } + }), + scrubPII: input.scrubPii, + })); + + if (options?.metadata) { + body.metadata = options.metadata; + } + + if (options?.scrubPii !== undefined) { + body.scrubPII = options.scrubPii; + } + + if (options?.temperature !== undefined) { + body.temperature = options.temperature; + } + + if (options?.tools?.length) { + body.tools = options.tools.map((tool) => ({ + function: { + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters, + }, + })); + } + + if (options?.toolChoice) { + body.toolChoice = options.toolChoice; + } + + if (options?.promptCacheRetention) { + body.promptCacheRetention = options.promptCacheRetention; + } + + if (options?.responseFormat) { + body.responseFormat = options.responseFormat; + } + + return body; + } + + private mapResponse(result: unknown): ConversationResponse { + const res = result as Record; + + const outputs: ConversationResult[] = ((res.outputs as any[]) ?? []).map((output: any) => { + const choices: ConversationResultChoice[] = (output.choices ?? []).map((choice: any) => { + const toolCalls: ConversationResultToolCall[] | undefined = choice.message?.toolCalls?.map((tc: any) => ({ + id: tc.id, + function: tc.function, + })); + + return { + finishReason: choice.finishReason ?? choice.finish_reason ?? "", + index: choice.index ?? 0, + message: choice.message + ? { + content: choice.message.content ?? "", + toolCalls: toolCalls?.length ? toolCalls : undefined, + } + : undefined, + }; + }); + + return { choices }; + }); + + return { + contextId: (res.contextID as string) ?? (res.context_id as string) ?? undefined, + outputs, + }; + } +} diff --git a/src/index.ts b/src/index.ts index c90de5eb..f61b7921 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,21 @@ import { toOrchestrationStatus, } from "./workflow/runtime/WorkflowRuntimeStatus"; +import { + ConversationOptions, + ConversationResponse, + ConversationInput, + ConversationMessage, + ConversationTool, + ConversationToolFunction, + ConversationToolCall, + ConversationResult, + ConversationResultChoice, + ConversationResultMessage, + ConversationResultToolCall, + ConversationMessageContent, +} from "./types/conversation/Conversation.type"; + export { DaprClient, DaprServer, @@ -90,4 +105,16 @@ export { WorkflowRuntimeStatus, fromOrchestrationStatus, toOrchestrationStatus, + ConversationOptions, + ConversationResponse, + ConversationInput, + ConversationMessage, + ConversationTool, + ConversationToolFunction, + ConversationToolCall, + ConversationResult, + ConversationResultChoice, + ConversationResultMessage, + ConversationResultToolCall, + ConversationMessageContent, }; diff --git a/src/interfaces/Client/IClientConversation.ts b/src/interfaces/Client/IClientConversation.ts new file mode 100644 index 00000000..2228d632 --- /dev/null +++ b/src/interfaces/Client/IClientConversation.ts @@ -0,0 +1,29 @@ +/* +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ConversationInput, ConversationOptions, ConversationResponse } from "../../types/conversation/Conversation.type"; + +export default interface IClientConversation { + /** + * Converse with a LLM service using the Alpha2 Conversation API. + * @param conversationComponentName - The name of the Conversation component to use + * @param inputs - The conversation inputs (required) + * @param options - Optional conversation parameters + * @returns The conversation response containing the LLM outputs + */ + converse( + conversationComponentName: string, + inputs: ConversationInput[], + options?: ConversationOptions, + ): Promise; +} diff --git a/src/types/conversation/Conversation.type.ts b/src/types/conversation/Conversation.type.ts new file mode 100644 index 00000000..891ee6ba --- /dev/null +++ b/src/types/conversation/Conversation.type.ts @@ -0,0 +1,181 @@ +/* +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Content part of a conversation message. + */ +export interface ConversationMessageContent { + text: string; +} + +/** + * A message from the developer role. + */ +export interface ConversationMessageOfDeveloper { + role: "developer"; + name?: string; + content: ConversationMessageContent[]; +} + +/** + * A message from the system role. + */ +export interface ConversationMessageOfSystem { + role: "system"; + name?: string; + content: ConversationMessageContent[]; +} + +/** + * A message from the user role. + */ +export interface ConversationMessageOfUser { + role: "user"; + name?: string; + content: ConversationMessageContent[]; +} + +/** + * A tool call generated by the model. + */ +export interface ConversationToolCall { + id?: string; + function?: { + name: string; + arguments: string; + }; +} + +/** + * A message from the assistant role. + */ +export interface ConversationMessageOfAssistant { + role: "assistant"; + name?: string; + content: ConversationMessageContent[]; + toolCalls?: ConversationToolCall[]; +} + +/** + * A message from a tool (tool result). + */ +export interface ConversationMessageOfTool { + role: "tool"; + toolId?: string; + name: string; + content: ConversationMessageContent[]; +} + +/** + * Union type for all possible conversation messages. + */ +export type ConversationMessage = + | ConversationMessageOfDeveloper + | ConversationMessageOfSystem + | ConversationMessageOfUser + | ConversationMessageOfAssistant + | ConversationMessageOfTool; + +/** + * Input for the conversation containing messages. + */ +export interface ConversationInput { + messages: ConversationMessage[]; + scrubPii?: boolean; +} + +/** + * A function tool that can be used during the conversation. + */ +export interface ConversationToolFunction { + name: string; + description?: string; + parameters?: Record; +} + +/** + * A tool available during the conversation. + */ +export interface ConversationTool { + function: ConversationToolFunction; +} + +/** + * Options for the Alpha2 Conversation API. + */ +export interface ConversationOptions { + /** The ID of an existing chat context */ + contextId?: string; + /** Metadata key-value pairs */ + metadata?: Record; + /** Scrub PII data that comes back from the LLM */ + scrubPii?: boolean; + /** Temperature for the LLM (0.0 to 2.0) */ + temperature?: number; + /** Tools available to the LLM during the conversation */ + tools?: ConversationTool[]; + /** Controls which tool is called by the model (none, auto, required, or specific tool name) */ + toolChoice?: string; + /** Duration for prompt cache retention (e.g. "5m", "1h") */ + promptCacheRetention?: string; + /** Expected response format from the LLM */ + responseFormat?: Record; +} + +/** + * A tool call in a result message. + */ +export interface ConversationResultToolCall { + id?: string; + function?: { + name: string; + arguments: string; + }; +} + +/** + * The message content within a result choice. + */ +export interface ConversationResultMessage { + content: string; + toolCalls?: ConversationResultToolCall[]; +} + +/** + * A choice in the conversation result (inspired by OpenAI ChatCompletionChoice). + */ +export interface ConversationResultChoice { + /** Reason the model stopped: "stop", "length", "tool_calls", "content_filter" */ + finishReason: string; + /** Index of the choice */ + index: number; + /** The message generated by the model */ + message?: ConversationResultMessage; +} + +/** + * A single conversation result (inspired by OpenAI ChatCompletion). + */ +export interface ConversationResult { + choices: ConversationResultChoice[]; +} + +/** + * Response from the Alpha2 Conversation API. + */ +export interface ConversationResponse { + /** The context ID for continuing the conversation */ + contextId?: string; + /** Array of results from the LLM */ + outputs: ConversationResult[]; +} diff --git a/test/e2e/common/conversation.test.ts b/test/e2e/common/conversation.test.ts new file mode 100644 index 00000000..ef4d0be7 --- /dev/null +++ b/test/e2e/common/conversation.test.ts @@ -0,0 +1,217 @@ +/* +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Network, StartedNetwork } from "testcontainers"; +import { DaprContainer, StartedDaprContainer } from "@dapr/testcontainer-node"; +import { CommunicationProtocolEnum, DaprClient, LogLevel } from "../../../src"; +import { + ConversationInput, + ConversationOptions, + ConversationResponse, +} from "../../../src/types/conversation/Conversation.type"; +import { + buildConversationEchoComponent, + runWithCleanupErrorSuppression, +} from "../helpers/containers"; + +// The Conversation API alpha2 requires Dapr >= 1.16. +// Use DAPR_RUNTIME_VER to override, otherwise default to 1.16.0. +const CONVERSATION_DAPR_VERSION = process.env.DAPR_RUNTIME_VER || "1.16.0"; +const CONVERSATION_RUNTIME_IMAGE = `daprio/daprd:${CONVERSATION_DAPR_VERSION}`; +const CONVERSATION_PLACEMENT_IMAGE = `daprio/placement:${CONVERSATION_DAPR_VERSION}`; +const CONVERSATION_SCHEDULER_IMAGE = `daprio/scheduler:${CONVERSATION_DAPR_VERSION}`; + +const loggerSettings = { + level: LogLevel.Debug, +}; + +/** + * Shared test suite for the Conversation API. Called once per protocol + * (HTTP and gRPC) so we exercise both transports against a real Dapr sidecar. + */ +function conversationTestSuite(protocol: "HTTP" | "GRPC") { + let client: DaprClient; + let network: StartedNetwork; + let daprContainer: StartedDaprContainer; + + beforeAll(async () => { + network = await new Network().start(); + + daprContainer = await new DaprContainer(CONVERSATION_RUNTIME_IMAGE) + .withPlacementImage(CONVERSATION_PLACEMENT_IMAGE) + .withSchedulerImage(CONVERSATION_SCHEDULER_IMAGE) + .withNetwork(network) + .withAppChannelAddress("host.testcontainers.internal") + .withComponent(buildConversationEchoComponent()) + .start(); + + const port = + protocol === "HTTP" ? daprContainer.getHttpPort().toString() : daprContainer.getGrpcPort().toString(); + const commProtocol = + protocol === "HTTP" ? CommunicationProtocolEnum.HTTP : CommunicationProtocolEnum.GRPC; + + client = new DaprClient({ + daprHost: daprContainer.getHost(), + daprPort: port, + communicationProtocol: commProtocol, + logger: loggerSettings, + }); + }, 180 * 1000); + + afterAll(async () => { + await runWithCleanupErrorSuppression(async () => { + await client.stop(); + await daprContainer.stop(); + await network.stop(); + }); + }); + + describe("converse", () => { + it("should send a simple user message and receive a response", async () => { + const inputs: ConversationInput[] = [ + { + messages: [ + { + role: "user", + content: [{ text: "Hello, Dapr!" }], + }, + ], + }, + ]; + + const response: ConversationResponse = await client.conversation.converse("echo", inputs); + + expect(response).toBeDefined(); + expect(response.outputs).toBeDefined(); + expect(response.outputs.length).toBeGreaterThan(0); + expect(response.outputs[0].choices).toBeDefined(); + expect(response.outputs[0].choices.length).toBeGreaterThan(0); + expect(response.outputs[0].choices[0].message).toBeDefined(); + expect(response.outputs[0].choices[0].message!.content).toBeDefined(); + }); + + it("should send a system message", async () => { + const inputs: ConversationInput[] = [ + { + messages: [ + { + role: "system", + content: [{ text: "You are a helpful assistant." }], + }, + { + role: "user", + content: [{ text: "Repeat after me: test" }], + }, + ], + }, + ]; + + const response = await client.conversation.converse("echo", inputs); + + expect(response).toBeDefined(); + expect(response.outputs).toBeDefined(); + expect(response.outputs.length).toBeGreaterThan(0); + }); + + it("should pass metadata in options", async () => { + const inputs: ConversationInput[] = [ + { + messages: [{ role: "user", content: [{ text: "test with metadata" }] }], + }, + ]; + + const options: ConversationOptions = { + metadata: { "test-key": "test-value" }, + }; + + const response = await client.conversation.converse("echo", inputs, options); + + expect(response).toBeDefined(); + expect(response.outputs).toBeDefined(); + expect(response.outputs.length).toBeGreaterThan(0); + }); + + it("should pass temperature in options", async () => { + const inputs: ConversationInput[] = [ + { + messages: [{ role: "user", content: [{ text: "test with temperature" }] }], + }, + ]; + + const options: ConversationOptions = { + temperature: 0.7, + }; + + const response = await client.conversation.converse("echo", inputs, options); + + expect(response).toBeDefined(); + expect(response.outputs).toBeDefined(); + expect(response.outputs.length).toBeGreaterThan(0); + }); + + it("should handle multiple inputs", async () => { + const inputs: ConversationInput[] = [ + { + messages: [{ role: "user", content: [{ text: "First message" }] }], + }, + { + messages: [{ role: "user", content: [{ text: "Second message" }] }], + }, + ]; + + const response = await client.conversation.converse("echo", inputs); + + expect(response).toBeDefined(); + expect(response.outputs).toBeDefined(); + // The echo component should return results for all inputs + expect(response.outputs.length).toBeGreaterThanOrEqual(1); + }); + + it("should handle multiple content items in a single message", async () => { + const inputs: ConversationInput[] = [ + { + messages: [ + { + role: "user", + content: [{ text: "Part 1" }, { text: "Part 2" }], + }, + ], + }, + ]; + + const response = await client.conversation.converse("echo", inputs); + + expect(response).toBeDefined(); + expect(response.outputs).toBeDefined(); + expect(response.outputs.length).toBeGreaterThan(0); + }); + + it("should reject requests for non-existent component", async () => { + const inputs: ConversationInput[] = [ + { + messages: [{ role: "user", content: [{ text: "test" }] }], + }, + ]; + + await expect(client.conversation.converse("non-existent-component", inputs)).rejects.toThrow(); + }); + }); +} + +describe("common/conversation/http", () => { + conversationTestSuite("HTTP"); +}); + +describe("common/conversation/grpc", () => { + conversationTestSuite("GRPC"); +}); diff --git a/test/e2e/helpers/containers.ts b/test/e2e/helpers/containers.ts index 40bcdf65..10c89000 100644 --- a/test/e2e/helpers/containers.ts +++ b/test/e2e/helpers/containers.ts @@ -183,6 +183,11 @@ export function buildCryptoLocalComponent(): Component { ]); } +/** conversation.echo component — echoes back the input for testing. */ +export function buildConversationEchoComponent(): Component { + return new Component("echo", "conversation.echo", "v1", []); +} + /** pubsub.in-memory component — used as a fallback so Dapr starts cleanly. */ export function buildInMemoryPubSubComponent(name = "pubsub"): Component { return new Component(name, "pubsub.in-memory", "v1", []); diff --git a/test/unit/protocols/grpc/conversation.test.ts b/test/unit/protocols/grpc/conversation.test.ts new file mode 100644 index 00000000..91b1388e --- /dev/null +++ b/test/unit/protocols/grpc/conversation.test.ts @@ -0,0 +1,672 @@ +/* +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import GRPCClientConversation from "../../../../src/implementation/Client/GRPCClient/conversation"; +import { + ConversationInput, + ConversationOptions, +} from "../../../../src/types/conversation/Conversation.type"; + +describe("grpc/conversation", () => { + const getMockClient = (requests: any[], response?: any) => { + const mockConverseAlpha2 = async (req: any) => { + requests.push(req); + return response ?? { contextId: undefined, outputs: [] }; + }; + const mockClient = { + getClient: () => { + return { converseAlpha2: mockConverseAlpha2 }; + }, + } as any; + return mockClient; + }; + + describe("converse with minimal inputs", () => { + it("should call converseAlpha2 with correct component name", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "Hello" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + expect(requests).toHaveLength(1); + expect(requests[0].name).toBe("my-llm"); + }); + + it("should build proto input with user message", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "Hello" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + expect(requests[0].inputs).toHaveLength(1); + const msg = requests[0].inputs[0].messages[0]; + expect(msg.messageTypes.case).toBe("ofUser"); + expect(msg.messageTypes.value.content).toHaveLength(1); + expect(msg.messageTypes.value.content[0].text).toBe("Hello"); + }); + }); + + describe("converse with all message roles", () => { + it("should map developer message to ofDeveloper", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { messages: [{ role: "developer", name: "dev1", content: [{ text: "dev msg" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + const msg = requests[0].inputs[0].messages[0]; + expect(msg.messageTypes.case).toBe("ofDeveloper"); + expect(msg.messageTypes.value.name).toBe("dev1"); + expect(msg.messageTypes.value.content[0].text).toBe("dev msg"); + }); + + it("should map system message to ofSystem", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { messages: [{ role: "system", name: "sys1", content: [{ text: "system msg" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + const msg = requests[0].inputs[0].messages[0]; + expect(msg.messageTypes.case).toBe("ofSystem"); + expect(msg.messageTypes.value.name).toBe("sys1"); + expect(msg.messageTypes.value.content[0].text).toBe("system msg"); + }); + + it("should map user message to ofUser", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", name: "usr1", content: [{ text: "user msg" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + const msg = requests[0].inputs[0].messages[0]; + expect(msg.messageTypes.case).toBe("ofUser"); + expect(msg.messageTypes.value.name).toBe("usr1"); + expect(msg.messageTypes.value.content[0].text).toBe("user msg"); + }); + + it("should map assistant message to ofAssistant", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { messages: [{ role: "assistant", name: "asst", content: [{ text: "assistant msg" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + const msg = requests[0].inputs[0].messages[0]; + expect(msg.messageTypes.case).toBe("ofAssistant"); + expect(msg.messageTypes.value.name).toBe("asst"); + expect(msg.messageTypes.value.content[0].text).toBe("assistant msg"); + }); + + it("should map assistant message with tool calls", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { + messages: [ + { + role: "assistant", + content: [{ text: "calling tool" }], + toolCalls: [ + { id: "tc-1", function: { name: "getWeather", arguments: '{"city":"NYC"}' } }, + ], + }, + ], + }, + ]; + + await conversation.converse("my-llm", inputs); + + const msg = requests[0].inputs[0].messages[0]; + expect(msg.messageTypes.case).toBe("ofAssistant"); + expect(msg.messageTypes.value.toolCalls).toHaveLength(1); + expect(msg.messageTypes.value.toolCalls[0].id).toBe("tc-1"); + expect(msg.messageTypes.value.toolCalls[0].toolTypes.case).toBe("function"); + expect(msg.messageTypes.value.toolCalls[0].toolTypes.value.name).toBe("getWeather"); + expect(msg.messageTypes.value.toolCalls[0].toolTypes.value.arguments).toBe('{"city":"NYC"}'); + }); + + it("should map tool message to ofTool", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { + messages: [ + { role: "tool", toolId: "tc-1", name: "getWeather", content: [{ text: "sunny" }] }, + ], + }, + ]; + + await conversation.converse("my-llm", inputs); + + const msg = requests[0].inputs[0].messages[0]; + expect(msg.messageTypes.case).toBe("ofTool"); + expect(msg.messageTypes.value.toolId).toBe("tc-1"); + expect(msg.messageTypes.value.name).toBe("getWeather"); + expect(msg.messageTypes.value.content[0].text).toBe("sunny"); + }); + + it("should handle all roles in a single input", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { + messages: [ + { role: "developer", content: [{ text: "dev" }] }, + { role: "system", content: [{ text: "sys" }] }, + { role: "user", content: [{ text: "usr" }] }, + { role: "assistant", content: [{ text: "asst" }] }, + { role: "tool", name: "fn", content: [{ text: "tool" }] }, + ], + }, + ]; + + await conversation.converse("my-llm", inputs); + + const messages = requests[0].inputs[0].messages; + expect(messages).toHaveLength(5); + expect(messages[0].messageTypes.case).toBe("ofDeveloper"); + expect(messages[1].messageTypes.case).toBe("ofSystem"); + expect(messages[2].messageTypes.case).toBe("ofUser"); + expect(messages[3].messageTypes.case).toBe("ofAssistant"); + expect(messages[4].messageTypes.case).toBe("ofTool"); + }); + }); + + describe("converse with options", () => { + const minimalInputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "hi" }] }] }, + ]; + + it("should pass contextId", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + await conversation.converse("my-llm", minimalInputs, { contextId: "ctx-123" }); + + expect(requests[0].contextId).toBe("ctx-123"); + }); + + it("should pass metadata", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + await conversation.converse("my-llm", minimalInputs, { + metadata: { key1: "val1", key2: "val2" }, + }); + + expect(requests[0].metadata).toEqual({ key1: "val1", key2: "val2" }); + }); + + it("should pass scrubPii", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + await conversation.converse("my-llm", minimalInputs, { scrubPii: true }); + + expect(requests[0].scrubPii).toBe(true); + }); + + it("should pass temperature", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + await conversation.converse("my-llm", minimalInputs, { temperature: 0.7 }); + + expect(requests[0].temperature).toBe(0.7); + }); + + it("should pass toolChoice", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + await conversation.converse("my-llm", minimalInputs, { toolChoice: "auto" }); + + expect(requests[0].toolChoice).toBe("auto"); + }); + + it("should default metadata to empty object when not provided", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + await conversation.converse("my-llm", minimalInputs); + + expect(requests[0].metadata).toEqual({}); + }); + }); + + describe("tool building", () => { + it("should build tools with function schema", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const options: ConversationOptions = { + tools: [ + { + function: { + name: "getWeather", + description: "Get weather info", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + }; + + await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "hi" }] }] }], + options, + ); + + expect(requests[0].tools).toHaveLength(1); + const tool = requests[0].tools[0]; + expect(tool.toolTypes.case).toBe("function"); + expect(tool.toolTypes.value.name).toBe("getWeather"); + expect(tool.toolTypes.value.description).toBe("Get weather info"); + }); + + it("should handle multiple tools", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const options: ConversationOptions = { + tools: [ + { function: { name: "fn1", description: "first" } }, + { function: { name: "fn2", description: "second" } }, + ], + }; + + await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "hi" }] }] }], + options, + ); + + expect(requests[0].tools).toHaveLength(2); + expect(requests[0].tools[0].toolTypes.value.name).toBe("fn1"); + expect(requests[0].tools[1].toolTypes.value.name).toBe("fn2"); + }); + + it("should handle no tools", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "hi" }] }] }], + ); + + expect(requests[0].tools).toEqual([]); + }); + }); + + describe("input-level scrubPii", () => { + it("should pass input scrubPii in proto input", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "hi" }] }], scrubPii: true }, + ]; + + await conversation.converse("my-llm", inputs); + + expect(requests[0].inputs[0].scrubPii).toBe(true); + }); + }); + + describe("response mapping", () => { + it("should map basic response with contextId", async () => { + const requests: any[] = []; + const protoResponse = { + contextId: "ctx-resp", + outputs: [ + { + choices: [ + { + finishReason: "stop", + index: BigInt(0), + message: { + content: "Hello there!", + toolCalls: [], + }, + }, + ], + }, + ], + }; + const conversation = new GRPCClientConversation(getMockClient(requests, protoResponse)); + + const res = await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "hi" }] }] }], + ); + + expect(res.contextId).toBe("ctx-resp"); + expect(res.outputs).toHaveLength(1); + expect(res.outputs[0].choices).toHaveLength(1); + expect(res.outputs[0].choices[0].finishReason).toBe("stop"); + expect(res.outputs[0].choices[0].index).toBe(0); + expect(res.outputs[0].choices[0].message?.content).toBe("Hello there!"); + expect(res.outputs[0].choices[0].message?.toolCalls).toBeUndefined(); + }); + + it("should handle empty outputs", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation( + getMockClient(requests, { contextId: undefined, outputs: [] }), + ); + + const res = await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "hi" }] }] }], + ); + + expect(res.contextId).toBeUndefined(); + expect(res.outputs).toEqual([]); + }); + + it("should map response with tool calls", async () => { + const requests: any[] = []; + const protoResponse = { + outputs: [ + { + choices: [ + { + finishReason: "tool_calls", + index: BigInt(0), + message: { + content: "", + toolCalls: [ + { + id: "call-1", + toolTypes: { + case: "function" as const, + value: { name: "getWeather", arguments: '{"city":"NYC"}' }, + }, + }, + ], + }, + }, + ], + }, + ], + }; + const conversation = new GRPCClientConversation(getMockClient(requests, protoResponse)); + + const res = await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "weather?" }] }] }], + ); + + const choice = res.outputs[0].choices[0]; + expect(choice.finishReason).toBe("tool_calls"); + expect(choice.message?.toolCalls).toHaveLength(1); + expect(choice.message?.toolCalls?.[0].id).toBe("call-1"); + expect(choice.message?.toolCalls?.[0].function?.name).toBe("getWeather"); + expect(choice.message?.toolCalls?.[0].function?.arguments).toBe('{"city":"NYC"}'); + }); + + it("should handle tool call with non-function case", async () => { + const requests: any[] = []; + const protoResponse = { + outputs: [ + { + choices: [ + { + finishReason: "tool_calls", + index: BigInt(0), + message: { + content: "", + toolCalls: [ + { + id: "call-2", + toolTypes: { case: undefined, value: undefined }, + }, + ], + }, + }, + ], + }, + ], + }; + const conversation = new GRPCClientConversation(getMockClient(requests, protoResponse)); + + const res = await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "hi" }] }] }], + ); + + const choice = res.outputs[0].choices[0]; + expect(choice.message?.toolCalls).toHaveLength(1); + expect(choice.message?.toolCalls?.[0].id).toBe("call-2"); + expect(choice.message?.toolCalls?.[0].function).toBeUndefined(); + }); + + it("should handle empty tool calls array in response", async () => { + const requests: any[] = []; + const protoResponse = { + outputs: [ + { + choices: [ + { + finishReason: "stop", + index: BigInt(0), + message: { content: "no tools", toolCalls: [] }, + }, + ], + }, + ], + }; + const conversation = new GRPCClientConversation(getMockClient(requests, protoResponse)); + + const res = await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "hi" }] }] }], + ); + + expect(res.outputs[0].choices[0].message?.toolCalls).toBeUndefined(); + }); + + it("should handle choice with no message", async () => { + const requests: any[] = []; + const protoResponse = { + outputs: [ + { + choices: [ + { finishReason: "stop", index: BigInt(0), message: undefined }, + ], + }, + ], + }; + const conversation = new GRPCClientConversation(getMockClient(requests, protoResponse)); + + const res = await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "hi" }] }] }], + ); + + expect(res.outputs[0].choices[0].message).toBeUndefined(); + }); + + it("should convert bigint index to number", async () => { + const requests: any[] = []; + const protoResponse = { + outputs: [ + { + choices: [ + { + finishReason: "stop", + index: BigInt(3), + message: { content: "test", toolCalls: [] }, + }, + ], + }, + ], + }; + const conversation = new GRPCClientConversation(getMockClient(requests, protoResponse)); + + const res = await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "hi" }] }] }], + ); + + expect(res.outputs[0].choices[0].index).toBe(3); + expect(typeof res.outputs[0].choices[0].index).toBe("number"); + }); + + it("should map multiple outputs with multiple choices", async () => { + const requests: any[] = []; + const protoResponse = { + contextId: "ctx-multi", + outputs: [ + { + choices: [ + { finishReason: "stop", index: BigInt(0), message: { content: "a", toolCalls: [] } }, + { finishReason: "stop", index: BigInt(1), message: { content: "b", toolCalls: [] } }, + ], + }, + { + choices: [ + { + finishReason: "length", + index: BigInt(0), + message: { content: "c", toolCalls: [] }, + }, + ], + }, + ], + }; + const conversation = new GRPCClientConversation(getMockClient(requests, protoResponse)); + + const res = await conversation.converse( + "my-llm", + [{ messages: [{ role: "user", content: [{ text: "hi" }] }] }], + ); + + expect(res.outputs).toHaveLength(2); + expect(res.outputs[0].choices).toHaveLength(2); + expect(res.outputs[0].choices[0].message?.content).toBe("a"); + expect(res.outputs[0].choices[1].message?.content).toBe("b"); + expect(res.outputs[1].choices).toHaveLength(1); + expect(res.outputs[1].choices[0].message?.content).toBe("c"); + }); + }); + + describe("multiple inputs", () => { + it("should handle multiple inputs", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "first" }] }] }, + { + messages: [ + { role: "system", content: [{ text: "be helpful" }] }, + { role: "user", content: [{ text: "second" }] }, + ], + scrubPii: true, + }, + ]; + + await conversation.converse("my-llm", inputs); + + expect(requests[0].inputs).toHaveLength(2); + expect(requests[0].inputs[0].messages).toHaveLength(1); + expect(requests[0].inputs[1].messages).toHaveLength(2); + expect(requests[0].inputs[1].scrubPii).toBe(true); + }); + }); + + describe("empty inputs", () => { + it("should handle empty inputs array", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + await conversation.converse("my-llm", []); + + expect(requests[0].inputs).toEqual([]); + }); + }); + + describe("assistant message without tool calls", () => { + it("should handle assistant without toolCalls property", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { + messages: [ + { role: "assistant", content: [{ text: "plain response" }] }, + ], + }, + ]; + + await conversation.converse("my-llm", inputs); + + const msg = requests[0].inputs[0].messages[0]; + expect(msg.messageTypes.case).toBe("ofAssistant"); + expect(msg.messageTypes.value.toolCalls).toEqual([]); + }); + }); + + describe("multiple content items", () => { + it("should handle messages with multiple content items", async () => { + const requests: any[] = []; + const conversation = new GRPCClientConversation(getMockClient(requests)); + + const inputs: ConversationInput[] = [ + { + messages: [ + { + role: "user", + content: [{ text: "part1" }, { text: "part2" }, { text: "part3" }], + }, + ], + }, + ]; + + await conversation.converse("my-llm", inputs); + + const msg = requests[0].inputs[0].messages[0]; + expect(msg.messageTypes.value.content).toHaveLength(3); + expect(msg.messageTypes.value.content[0].text).toBe("part1"); + expect(msg.messageTypes.value.content[1].text).toBe("part2"); + expect(msg.messageTypes.value.content[2].text).toBe("part3"); + }); + }); +}); diff --git a/test/unit/protocols/http/conversation.test.ts b/test/unit/protocols/http/conversation.test.ts new file mode 100644 index 00000000..e01e3a1e --- /dev/null +++ b/test/unit/protocols/http/conversation.test.ts @@ -0,0 +1,598 @@ +/* +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import HTTPClient from "../../../../src/implementation/Client/HTTPClient/HTTPClient"; +import HTTPClientConversation from "../../../../src/implementation/Client/HTTPClient/conversation"; +import { + ConversationInput, + ConversationOptions, +} from "../../../../src/types/conversation/Conversation.type"; + +describe("http/conversation", () => { + let client: HTTPClient; + let conversation: HTTPClientConversation; + let executeSpy: jest.SpyInstance; + + beforeEach(() => { + client = new HTTPClient({ + daprHost: "", + daprPort: "", + communicationProtocol: 0, + }); + conversation = new HTTPClientConversation(client); + executeSpy = jest.spyOn(client, "executeWithApiVersion").mockResolvedValue({}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("converse with minimal inputs", () => { + it("should call executeWithApiVersion with correct URL and method", async () => { + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "Hello" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + expect(executeSpy).toHaveBeenCalledWith( + "v1.0-alpha2", + "/conversation/my-llm/converse", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + it("should build request body with single user message", async () => { + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "Hello" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + const body = executeSpy.mock.calls[0][2].body; + expect(body).toEqual({ + inputs: [ + { + messages: [{ ofUser: { name: undefined, content: [{ text: "Hello" }] } }], + scrubPII: undefined, + }, + ], + }); + }); + }); + + describe("converse with all message roles", () => { + it("should map developer message correctly", async () => { + const inputs: ConversationInput[] = [ + { messages: [{ role: "developer", name: "dev1", content: [{ text: "dev msg" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.inputs[0].messages[0]).toEqual({ + ofDeveloper: { name: "dev1", content: [{ text: "dev msg" }] }, + }); + }); + + it("should map system message correctly", async () => { + const inputs: ConversationInput[] = [ + { messages: [{ role: "system", name: "sys1", content: [{ text: "system msg" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.inputs[0].messages[0]).toEqual({ + ofSystem: { name: "sys1", content: [{ text: "system msg" }] }, + }); + }); + + it("should map user message correctly", async () => { + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", name: "usr1", content: [{ text: "user msg" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.inputs[0].messages[0]).toEqual({ + ofUser: { name: "usr1", content: [{ text: "user msg" }] }, + }); + }); + + it("should map assistant message correctly", async () => { + const inputs: ConversationInput[] = [ + { messages: [{ role: "assistant", name: "asst", content: [{ text: "assistant msg" }] }] }, + ]; + + await conversation.converse("my-llm", inputs); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.inputs[0].messages[0]).toEqual({ + ofAssistant: { name: "asst", content: [{ text: "assistant msg" }], toolCalls: undefined }, + }); + }); + + it("should map assistant message with tool calls", async () => { + const inputs: ConversationInput[] = [ + { + messages: [ + { + role: "assistant", + name: "asst", + content: [{ text: "calling tool" }], + toolCalls: [ + { id: "tc-1", function: { name: "getWeather", arguments: '{"city":"NYC"}' } }, + ], + }, + ], + }, + ]; + + await conversation.converse("my-llm", inputs); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.inputs[0].messages[0]).toEqual({ + ofAssistant: { + name: "asst", + content: [{ text: "calling tool" }], + toolCalls: [ + { id: "tc-1", function: { name: "getWeather", arguments: '{"city":"NYC"}' } }, + ], + }, + }); + }); + + it("should map tool message correctly", async () => { + const inputs: ConversationInput[] = [ + { + messages: [ + { role: "tool", toolId: "tc-1", name: "getWeather", content: [{ text: "sunny" }] }, + ], + }, + ]; + + await conversation.converse("my-llm", inputs); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.inputs[0].messages[0]).toEqual({ + ofTool: { toolId: "tc-1", name: "getWeather", content: [{ text: "sunny" }] }, + }); + }); + + it("should handle all roles in a single input", async () => { + const inputs: ConversationInput[] = [ + { + messages: [ + { role: "developer", content: [{ text: "dev" }] }, + { role: "system", content: [{ text: "sys" }] }, + { role: "user", content: [{ text: "usr" }] }, + { role: "assistant", content: [{ text: "asst" }] }, + { role: "tool", name: "fn", content: [{ text: "tool" }] }, + ], + }, + ]; + + await conversation.converse("my-llm", inputs); + + const body = executeSpy.mock.calls[0][2].body; + const messages = body.inputs[0].messages; + expect(messages).toHaveLength(5); + expect(messages[0]).toHaveProperty("ofDeveloper"); + expect(messages[1]).toHaveProperty("ofSystem"); + expect(messages[2]).toHaveProperty("ofUser"); + expect(messages[3]).toHaveProperty("ofAssistant"); + expect(messages[4]).toHaveProperty("ofTool"); + }); + }); + + describe("converse with options", () => { + const minimalInputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "hi" }] }] }, + ]; + + it("should include contextId as contextID", async () => { + await conversation.converse("my-llm", minimalInputs, { contextId: "ctx-123" }); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.contextID).toBe("ctx-123"); + }); + + it("should include metadata", async () => { + await conversation.converse("my-llm", minimalInputs, { + metadata: { key1: "val1", key2: "val2" }, + }); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.metadata).toEqual({ key1: "val1", key2: "val2" }); + }); + + it("should map scrubPii to scrubPII", async () => { + await conversation.converse("my-llm", minimalInputs, { scrubPii: true }); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.scrubPII).toBe(true); + }); + + it("should include scrubPii when false", async () => { + await conversation.converse("my-llm", minimalInputs, { scrubPii: false }); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.scrubPII).toBe(false); + }); + + it("should include temperature", async () => { + await conversation.converse("my-llm", minimalInputs, { temperature: 0.7 }); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.temperature).toBe(0.7); + }); + + it("should include temperature when 0", async () => { + await conversation.converse("my-llm", minimalInputs, { temperature: 0 }); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.temperature).toBe(0); + }); + + it("should include tools", async () => { + const options: ConversationOptions = { + tools: [ + { + function: { + name: "getWeather", + description: "Get weather info", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + }; + + await conversation.converse("my-llm", minimalInputs, options); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.tools).toEqual([ + { + function: { + name: "getWeather", + description: "Get weather info", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ]); + }); + + it("should not include tools when array is empty", async () => { + await conversation.converse("my-llm", minimalInputs, { tools: [] }); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.tools).toBeUndefined(); + }); + + it("should include toolChoice", async () => { + await conversation.converse("my-llm", minimalInputs, { toolChoice: "auto" }); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.toolChoice).toBe("auto"); + }); + + it("should include promptCacheRetention", async () => { + await conversation.converse("my-llm", minimalInputs, { promptCacheRetention: "5m" }); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.promptCacheRetention).toBe("5m"); + }); + + it("should include responseFormat", async () => { + await conversation.converse("my-llm", minimalInputs, { + responseFormat: { type: "json_object" }, + }); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.responseFormat).toEqual({ type: "json_object" }); + }); + + it("should include all options at once", async () => { + const options: ConversationOptions = { + contextId: "ctx-1", + metadata: { k: "v" }, + scrubPii: true, + temperature: 1.5, + tools: [{ function: { name: "fn1" } }], + toolChoice: "required", + promptCacheRetention: "1h", + responseFormat: { type: "text" }, + }; + + await conversation.converse("my-llm", minimalInputs, options); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.contextID).toBe("ctx-1"); + expect(body.metadata).toEqual({ k: "v" }); + expect(body.scrubPII).toBe(true); + expect(body.temperature).toBe(1.5); + expect(body.tools).toHaveLength(1); + expect(body.toolChoice).toBe("required"); + expect(body.promptCacheRetention).toBe("1h"); + expect(body.responseFormat).toEqual({ type: "text" }); + }); + }); + + describe("converse with input-level scrubPii", () => { + it("should map input scrubPii to scrubPII in inputs", async () => { + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "hi" }] }], scrubPii: true }, + ]; + + await conversation.converse("my-llm", inputs); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.inputs[0].scrubPII).toBe(true); + }); + }); + + describe("response mapping", () => { + const minimalInputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "hi" }] }] }, + ]; + + it("should map basic response with contextID", async () => { + executeSpy.mockResolvedValue({ + contextID: "ctx-resp", + outputs: [ + { + choices: [ + { + finishReason: "stop", + index: 0, + message: { content: "Hello there!" }, + }, + ], + }, + ], + }); + + const res = await conversation.converse("my-llm", minimalInputs); + + expect(res.contextId).toBe("ctx-resp"); + expect(res.outputs).toHaveLength(1); + expect(res.outputs[0].choices).toHaveLength(1); + expect(res.outputs[0].choices[0].finishReason).toBe("stop"); + expect(res.outputs[0].choices[0].index).toBe(0); + expect(res.outputs[0].choices[0].message?.content).toBe("Hello there!"); + expect(res.outputs[0].choices[0].message?.toolCalls).toBeUndefined(); + }); + + it("should map response with context_id (snake_case fallback)", async () => { + executeSpy.mockResolvedValue({ + context_id: "ctx-snake", + outputs: [], + }); + + const res = await conversation.converse("my-llm", minimalInputs); + expect(res.contextId).toBe("ctx-snake"); + }); + + it("should handle response with no contextID", async () => { + executeSpy.mockResolvedValue({ outputs: [] }); + + const res = await conversation.converse("my-llm", minimalInputs); + expect(res.contextId).toBeUndefined(); + }); + + it("should handle response with empty outputs", async () => { + executeSpy.mockResolvedValue({ outputs: [] }); + + const res = await conversation.converse("my-llm", minimalInputs); + expect(res.outputs).toEqual([]); + }); + + it("should handle response with missing outputs", async () => { + executeSpy.mockResolvedValue({}); + + const res = await conversation.converse("my-llm", minimalInputs); + expect(res.outputs).toEqual([]); + }); + + it("should map response with tool calls", async () => { + executeSpy.mockResolvedValue({ + outputs: [ + { + choices: [ + { + finishReason: "tool_calls", + index: 0, + message: { + content: "", + toolCalls: [ + { + id: "call-1", + function: { name: "getWeather", arguments: '{"city":"NYC"}' }, + }, + ], + }, + }, + ], + }, + ], + }); + + const res = await conversation.converse("my-llm", minimalInputs); + + const choice = res.outputs[0].choices[0]; + expect(choice.finishReason).toBe("tool_calls"); + expect(choice.message?.toolCalls).toHaveLength(1); + expect(choice.message?.toolCalls?.[0].id).toBe("call-1"); + expect(choice.message?.toolCalls?.[0].function?.name).toBe("getWeather"); + expect(choice.message?.toolCalls?.[0].function?.arguments).toBe('{"city":"NYC"}'); + }); + + it("should handle response with empty toolCalls array", async () => { + executeSpy.mockResolvedValue({ + outputs: [ + { + choices: [ + { + finishReason: "stop", + index: 0, + message: { content: "hi", toolCalls: [] }, + }, + ], + }, + ], + }); + + const res = await conversation.converse("my-llm", minimalInputs); + expect(res.outputs[0].choices[0].message?.toolCalls).toBeUndefined(); + }); + + it("should handle choice with no message", async () => { + executeSpy.mockResolvedValue({ + outputs: [ + { + choices: [ + { finishReason: "stop", index: 0 }, + ], + }, + ], + }); + + const res = await conversation.converse("my-llm", minimalInputs); + expect(res.outputs[0].choices[0].message).toBeUndefined(); + }); + + it("should use finish_reason snake_case fallback", async () => { + executeSpy.mockResolvedValue({ + outputs: [ + { + choices: [ + { + finish_reason: "length", + index: 1, + message: { content: "truncated" }, + }, + ], + }, + ], + }); + + const res = await conversation.converse("my-llm", minimalInputs); + expect(res.outputs[0].choices[0].finishReason).toBe("length"); + expect(res.outputs[0].choices[0].index).toBe(1); + }); + + it("should default finishReason to empty string and index to 0", async () => { + executeSpy.mockResolvedValue({ + outputs: [ + { + choices: [ + { message: { content: "test" } }, + ], + }, + ], + }); + + const res = await conversation.converse("my-llm", minimalInputs); + expect(res.outputs[0].choices[0].finishReason).toBe(""); + expect(res.outputs[0].choices[0].index).toBe(0); + }); + + it("should map multiple outputs with multiple choices", async () => { + executeSpy.mockResolvedValue({ + contextID: "ctx-multi", + outputs: [ + { + choices: [ + { finishReason: "stop", index: 0, message: { content: "a" } }, + { finishReason: "stop", index: 1, message: { content: "b" } }, + ], + }, + { + choices: [ + { finishReason: "length", index: 0, message: { content: "c" } }, + ], + }, + ], + }); + + const res = await conversation.converse("my-llm", minimalInputs); + + expect(res.outputs).toHaveLength(2); + expect(res.outputs[0].choices).toHaveLength(2); + expect(res.outputs[0].choices[0].message?.content).toBe("a"); + expect(res.outputs[0].choices[1].message?.content).toBe("b"); + expect(res.outputs[1].choices).toHaveLength(1); + expect(res.outputs[1].choices[0].message?.content).toBe("c"); + }); + }); + + describe("multiple inputs", () => { + it("should handle multiple inputs with different messages", async () => { + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "first" }] }] }, + { + messages: [ + { role: "system", content: [{ text: "be helpful" }] }, + { role: "user", content: [{ text: "second" }] }, + ], + scrubPii: true, + }, + ]; + + await conversation.converse("my-llm", inputs); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.inputs).toHaveLength(2); + expect(body.inputs[0].messages).toHaveLength(1); + expect(body.inputs[1].messages).toHaveLength(2); + expect(body.inputs[1].scrubPII).toBe(true); + }); + }); + + describe("empty inputs", () => { + it("should handle empty inputs array", async () => { + await conversation.converse("my-llm", []); + + const body = executeSpy.mock.calls[0][2].body; + expect(body.inputs).toEqual([]); + }); + }); + + describe("error handling", () => { + it("should propagate errors from executeWithApiVersion", async () => { + const error = new Error("network error"); + executeSpy.mockRejectedValue(error); + + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "hi" }] }] }, + ]; + + await expect(conversation.converse("my-llm", inputs)).rejects.toThrow("network error"); + }); + + it("should propagate the original error object", async () => { + const error = new Error("timeout"); + executeSpy.mockRejectedValue(error); + + const inputs: ConversationInput[] = [ + { messages: [{ role: "user", content: [{ text: "hi" }] }] }, + ]; + + await expect(conversation.converse("my-llm", inputs)).rejects.toBe(error); + }); + }); +});