From 0b5950e9d7a1cf32f0efe999e5f65589786deed4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 03:21:29 +0000 Subject: [PATCH 1/7] feat: Add Conversation API Alpha2 support Implements the Alpha2 Conversation API in the JS SDK with support for: - Multi-role messages (developer, system, user, assistant, tool) - Tool calling (function tools with parameters) - Tool choice control - PII scrubbing - Temperature control - Context/session management Adds both gRPC and HTTP client implementations. Agent-Logs-Url: https://github.com/dapr/js-sdk/sessions/86aa791b-557e-4db5-84a3-4f1aef4e1d0d Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com> --- src/implementation/Client/DaprClient.ts | 6 + .../Client/GRPCClient/conversation.ts | 206 ++++++++++++++++++ .../Client/HTTPClient/conversation.ts | 149 +++++++++++++ src/index.ts | 27 +++ src/interfaces/Client/IClientConversation.ts | 23 ++ src/types/conversation/Conversation.type.ts | 181 +++++++++++++++ 6 files changed, 592 insertions(+) create mode 100644 src/implementation/Client/GRPCClient/conversation.ts create mode 100644 src/implementation/Client/HTTPClient/conversation.ts create mode 100644 src/interfaces/Client/IClientConversation.ts create mode 100644 src/types/conversation/Conversation.type.ts 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..5abe415a --- /dev/null +++ b/src/implementation/Client/GRPCClient/conversation.ts @@ -0,0 +1,206 @@ +/* +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 { + ConversationRequest, + 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(request: ConversationRequest): Promise { + const client = await this.client.getClient(); + + const protoInputs = request.inputs.map((input) => this.buildInput(input)); + const protoTools = (request.tools ?? []).map((tool) => this.buildTool(tool)); + + const protoRequest = create(ConversationRequestAlpha2Schema, { + name: request.name, + contextId: request.contextId, + inputs: protoInputs, + metadata: request.metadata ?? {}, + scrubPii: request.scrubPii, + temperature: request.temperature, + tools: protoTools, + toolChoice: request.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..b52156bb --- /dev/null +++ b/src/implementation/Client/HTTPClient/conversation.ts @@ -0,0 +1,149 @@ +/* +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 { + ConversationRequest, + ConversationResponse, + 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(request: ConversationRequest): Promise { + const body = this.buildRequestBody(request); + + try { + const result = await this.client.executeWithApiVersion( + "v1.0-alpha2", + `/conversation/${request.name}/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(request: ConversationRequest): object { + const body: Record = {}; + + if (request.contextId) { + body.contextID = request.contextId; + } + + body.inputs = request.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 (request.metadata) { + body.metadata = request.metadata; + } + + if (request.scrubPii !== undefined) { + body.scrubPII = request.scrubPii; + } + + if (request.temperature !== undefined) { + body.temperature = request.temperature; + } + + if (request.tools?.length) { + body.tools = request.tools.map((tool) => ({ + function: { + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters, + }, + })); + } + + if (request.toolChoice) { + body.toolChoice = request.toolChoice; + } + + 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..fd1ebbf5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,21 @@ import { toOrchestrationStatus, } from "./workflow/runtime/WorkflowRuntimeStatus"; +import { + ConversationRequest, + 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, + ConversationRequest, + 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..13754053 --- /dev/null +++ b/src/interfaces/Client/IClientConversation.ts @@ -0,0 +1,23 @@ +/* +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 { ConversationRequest, ConversationResponse } from "../../types/conversation/Conversation.type"; + +export default interface IClientConversation { + /** + * Converse with a LLM service using the Alpha2 Conversation API. + * @param request - The conversation request parameters + * @returns The conversation response containing the LLM outputs + */ + converse(request: ConversationRequest): Promise; +} diff --git a/src/types/conversation/Conversation.type.ts b/src/types/conversation/Conversation.type.ts new file mode 100644 index 00000000..93924794 --- /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; +} + +/** + * Request for the Alpha2 Conversation API. + */ +export interface ConversationRequest { + /** The name of the Conversation component */ + name: string; + /** The ID of an existing chat context */ + contextId?: string; + /** Inputs for the conversation */ + inputs: ConversationInput[]; + /** 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; +} + +/** + * 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[]; +} From 0023ebae6a8ae6f9185da026088c2f44a7737933 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 03:32:15 +0000 Subject: [PATCH 2/7] proposal: Support Conversation API Alpha2 This is a tracking PR for implementing the Alpha2 Conversation API in the Dapr JS SDK. See PR description for full details. Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com> From 94f8dabce00cba945b41c9fa628bab69531f4f34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 03:57:49 +0000 Subject: [PATCH 3/7] docs: add GitHub issue for missing Jobs API implementation support Agent-Logs-Url: https://github.com/dapr/js-sdk/sessions/ebf22fdd-6d4d-4d62-9776-b5ed762be140 Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com> --- issues/jobs-api-support.md | 285 +++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 issues/jobs-api-support.md diff --git a/issues/jobs-api-support.md b/issues/jobs-api-support.md new file mode 100644 index 00000000..9fc9e306 --- /dev/null +++ b/issues/jobs-api-support.md @@ -0,0 +1,285 @@ +--- +name: Feature Request +about: Implement Jobs API (Alpha1) support in the JavaScript SDK +title: "feat: Implement Jobs API support (ScheduleJob / GetJob / DeleteJob / ListJobs)" +labels: kind/enhancement +assignees: "" +--- + +## Feature Request + +### Describe the feature + +The Dapr runtime exposes a **Jobs API** (currently Alpha1) that enables applications to +schedule, retrieve, delete, and list jobs — as well as receive triggered job events via +an app callback. The JS SDK already has the generated proto/ConnectRPC bindings and a +no-op server stub for `onJobEventAlpha1`, but **no high-level client or server API** is +exposed to users. + +This issue tracks the work required to add first-class Jobs support to the JS SDK. + +--- + +### Dapr Jobs API surface (proto RPCs) + +From `src/proto/dapr/proto/runtime/v1/dapr.proto`: + +````proto +service Dapr { + // Create and schedule a job + rpc ScheduleJobAlpha1(ScheduleJobRequest) returns (ScheduleJobResponse) {} + + // Gets a scheduled job + rpc GetJobAlpha1(GetJobRequest) returns (GetJobResponse) {} + + // Delete a job + rpc DeleteJobAlpha1(DeleteJobRequest) returns (DeleteJobResponse) {} + + // Delete jobs by name prefix + rpc DeleteJobsByPrefixAlpha1(DeleteJobsByPrefixRequestAlpha1) returns (DeleteJobsByPrefixResponseAlpha1) {} + + // List all jobs + rpc ListJobsAlpha1(ListJobsRequestAlpha1) returns (ListJobsResponseAlpha1) {} +} +```` + +From `src/proto/dapr/proto/runtime/v1/appcallback.proto`: + +````proto +service AppCallbackAlpha { + // Sends job back to the app's endpoint at trigger time. + rpc OnJobEventAlpha1 (JobEventRequest) returns (JobEventResponse); +} +```` + +--- + +### Job message definition + +From `src/proto/dapr/proto/runtime/v1/jobs.proto`: + +````proto +message Job { + string name = 1; + optional string schedule = 2; // cron or @every/@daily/etc. + optional uint32 repeats = 3; // run N times (omit = indefinite) + optional string due_time = 4; // RFC3339, Go duration, or ISO8601 + optional string ttl = 5; // expiration + google.protobuf.Any data = 6; // arbitrary payload + optional common.v1.JobFailurePolicy failure_policy = 7; +} + +message ScheduleJobRequest { + Job job = 1; + bool overwrite = 2; +} +message ScheduleJobResponse {} + +message GetJobRequest { string name = 1; } +message GetJobResponse { Job job = 1; } + +message DeleteJobRequest { string name = 1; } +message DeleteJobResponse {} + +message DeleteJobsByPrefixRequestAlpha1 { optional string name_prefix = 1; } +message DeleteJobsByPrefixResponseAlpha1 {} + +message ListJobsRequestAlpha1 {} +message ListJobsResponseAlpha1 { repeated Job jobs = 1; } +```` + +Failure policy (from `src/proto/dapr/proto/common/v1/common.proto`): + +````proto +message JobFailurePolicy { + oneof policy { + JobFailurePolicyDrop drop = 1; + JobFailurePolicyConstant constant = 2; + } +} +message JobFailurePolicyDrop {} +message JobFailurePolicyConstant { + google.protobuf.Duration interval = 1; + optional uint32 max_retries = 2; +} +```` + +--- + +### Current state in the JS SDK + +| Area | Status | +|------|--------| +| Proto definitions (`src/proto/dapr/proto/runtime/v1/jobs.proto`) | ✅ Present | +| ConnectRPC bindings (`dapr_connect.{js,d.ts}`) | ✅ Generated | +| Server stub `onJobEventAlpha1` in `GRPCServerImpl.ts` | ⚠️ No-op (returns empty response) | +| Client interface for Jobs | ❌ Missing | +| Client implementation (gRPC) | ❌ Missing | +| Client implementation (HTTP) | ❌ Missing | +| User-facing TypeScript types | ❌ Missing | +| Job event handler registration on `DaprServer` | ❌ Missing | +| Tests | ❌ Missing | +| Exports from package index | ❌ Missing | + +--- + +### Proposed implementation scope + +#### 1. User-facing types (`src/types/jobs/`) + +````typescript +export interface DaprJob { + name: string; + schedule?: string; + repeats?: number; + dueTime?: string; + ttl?: string; + data?: unknown; + failurePolicy?: JobFailurePolicy; +} + +export type JobFailurePolicy = + | { type: "drop" } + | { type: "constant"; interval: string; maxRetries?: number }; + +export interface ScheduleJobOptions { + job: DaprJob; + overwrite?: boolean; +} +```` + +#### 2. Client interface (`src/interfaces/Client/IClientJobs.ts`) + +````typescript +export interface IClientJobs { + schedule(options: ScheduleJobOptions): Promise; + get(name: string): Promise; + delete(name: string): Promise; + deleteByPrefix(namePrefix?: string): Promise; + list(): Promise; +} +```` + +#### 3. gRPC client implementation (`src/implementation/Client/GRPCClient/jobs.ts`) + +Map user-facing types ↔ proto schemas and call: +- `client.scheduleJobAlpha1()` +- `client.getJobAlpha1()` +- `client.deleteJobAlpha1()` +- `client.deleteJobsByPrefixAlpha1()` +- `client.listJobsAlpha1()` + +Handle `google.protobuf.Any` serialization for `data` transparently. + +#### 4. HTTP client implementation (`src/implementation/Client/HTTPClient/jobs.ts`) + +Endpoints: +- `POST /v1.0-alpha1/jobs/` — schedule +- `GET /v1.0-alpha1/jobs/` — get +- `DELETE /v1.0-alpha1/jobs/` — delete + +#### 5. DaprClient integration + +Expose a `jobs` property on `DaprClient`: + +````typescript +const client = new DaprClient(); +await client.jobs.schedule({ job: { name: "my-job", schedule: "@every 5m" } }); +const job = await client.jobs.get("my-job"); +const all = await client.jobs.list(); +await client.jobs.delete("my-job"); +await client.jobs.deleteByPrefix("my-"); +```` + +#### 6. Server-side: job handler registration + +Allow registering named handlers on `DaprServer`: + +````typescript +const server = new DaprServer(); + +server.jobs.on("send-reminder", async (event) => { + const payload = event.data; + console.log("Triggered:", payload); +}); + +await server.start(); +```` + +#### 7. Implement `onJobEventAlpha1` routing + +Update `GRPCServerImpl.ts` to route incoming `JobEventRequest` messages to the +appropriate registered handler by job name. + +#### 8. Exports + +Export all new types and interfaces from `src/index.ts`. + +#### 9. Tests + +- Unit tests for type ↔ proto mapping (both directions) +- E2E test with a scheduler component if testcontainers supports it + +--- + +### Target usage examples + +**Scheduling a job:** + +````typescript +import { DaprClient } from "@dapr/dapr"; + +const client = new DaprClient(); + +await client.jobs.schedule({ + job: { + name: "send-report", + schedule: "0 0 9 * * *", // 9 AM daily + data: { reportType: "weekly", recipients: ["team@example.com"] }, + failurePolicy: { type: "constant", interval: "30s", maxRetries: 3 }, + }, + overwrite: true, +}); +```` + +**Receiving a triggered job:** + +````typescript +import { DaprServer } from "@dapr/dapr"; + +const server = new DaprServer(); + +server.jobs.on("send-report", async (event) => { + const { reportType, recipients } = event.data; + await generateAndSendReport(reportType, recipients); +}); + +await server.start(); +```` + +--- + +### References + +| Resource | Location | +|----------|----------| +| Proto messages | `src/proto/dapr/proto/runtime/v1/jobs.proto` | +| Proto RPCs | `src/proto/dapr/proto/runtime/v1/dapr.proto:235-246` | +| App callback | `src/proto/dapr/proto/runtime/v1/appcallback.proto:70` | +| Failure policy | `src/proto/dapr/proto/common/v1/common.proto:163-178` | +| ConnectRPC bindings | `src/proto/dapr/proto/runtime/v1/dapr_connect.{js,d.ts}` | +| Existing server stub | `src/implementation/Server/GRPCServer/GRPCServerImpl.ts:396` | +| Dapr docs | https://docs.dapr.io/reference/api/jobs_api/ | + +--- + +### Acceptance criteria + +- [ ] `DaprClient` exposes `client.jobs` with `schedule()`, `get()`, `delete()`, `deleteByPrefix()`, `list()` +- [ ] Both gRPC and HTTP transports are implemented +- [ ] `DaprServer` supports registering job event handlers by name +- [ ] `onJobEventAlpha1` in `GRPCServerImpl` routes events to registered handlers +- [ ] All proto fields are represented in user-facing types (including `failure_policy`, `overwrite`, `data`) +- [ ] `google.protobuf.Any` payload is serialized/deserialized transparently +- [ ] Unit tests cover type mapping between user types and proto schemas +- [ ] New types/interfaces are exported from the package index From c5d621d1e6abeadc641bbb6cad404f2cf3390848 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 02:21:48 +0000 Subject: [PATCH 4/7] refactor: address PR review feedback - rename ConversationRequest to ConversationOptions, extract inputs as required param, add promptCacheRetention and responseFormat, rename name to conversationComponentName, delete issues/jobs-api-support.md Agent-Logs-Url: https://github.com/dapr/js-sdk/sessions/485d7ce7-4ca6-420b-be90-98ffa19b25db Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com> --- issues/jobs-api-support.md | 285 ------------------ .../Client/GRPCClient/conversation.ts | 24 +- .../Client/HTTPClient/conversation.ts | 49 +-- src/index.ts | 4 +- src/interfaces/Client/IClientConversation.ts | 12 +- src/types/conversation/Conversation.type.ts | 12 +- 6 files changed, 62 insertions(+), 324 deletions(-) delete mode 100644 issues/jobs-api-support.md diff --git a/issues/jobs-api-support.md b/issues/jobs-api-support.md deleted file mode 100644 index 9fc9e306..00000000 --- a/issues/jobs-api-support.md +++ /dev/null @@ -1,285 +0,0 @@ ---- -name: Feature Request -about: Implement Jobs API (Alpha1) support in the JavaScript SDK -title: "feat: Implement Jobs API support (ScheduleJob / GetJob / DeleteJob / ListJobs)" -labels: kind/enhancement -assignees: "" ---- - -## Feature Request - -### Describe the feature - -The Dapr runtime exposes a **Jobs API** (currently Alpha1) that enables applications to -schedule, retrieve, delete, and list jobs — as well as receive triggered job events via -an app callback. The JS SDK already has the generated proto/ConnectRPC bindings and a -no-op server stub for `onJobEventAlpha1`, but **no high-level client or server API** is -exposed to users. - -This issue tracks the work required to add first-class Jobs support to the JS SDK. - ---- - -### Dapr Jobs API surface (proto RPCs) - -From `src/proto/dapr/proto/runtime/v1/dapr.proto`: - -````proto -service Dapr { - // Create and schedule a job - rpc ScheduleJobAlpha1(ScheduleJobRequest) returns (ScheduleJobResponse) {} - - // Gets a scheduled job - rpc GetJobAlpha1(GetJobRequest) returns (GetJobResponse) {} - - // Delete a job - rpc DeleteJobAlpha1(DeleteJobRequest) returns (DeleteJobResponse) {} - - // Delete jobs by name prefix - rpc DeleteJobsByPrefixAlpha1(DeleteJobsByPrefixRequestAlpha1) returns (DeleteJobsByPrefixResponseAlpha1) {} - - // List all jobs - rpc ListJobsAlpha1(ListJobsRequestAlpha1) returns (ListJobsResponseAlpha1) {} -} -```` - -From `src/proto/dapr/proto/runtime/v1/appcallback.proto`: - -````proto -service AppCallbackAlpha { - // Sends job back to the app's endpoint at trigger time. - rpc OnJobEventAlpha1 (JobEventRequest) returns (JobEventResponse); -} -```` - ---- - -### Job message definition - -From `src/proto/dapr/proto/runtime/v1/jobs.proto`: - -````proto -message Job { - string name = 1; - optional string schedule = 2; // cron or @every/@daily/etc. - optional uint32 repeats = 3; // run N times (omit = indefinite) - optional string due_time = 4; // RFC3339, Go duration, or ISO8601 - optional string ttl = 5; // expiration - google.protobuf.Any data = 6; // arbitrary payload - optional common.v1.JobFailurePolicy failure_policy = 7; -} - -message ScheduleJobRequest { - Job job = 1; - bool overwrite = 2; -} -message ScheduleJobResponse {} - -message GetJobRequest { string name = 1; } -message GetJobResponse { Job job = 1; } - -message DeleteJobRequest { string name = 1; } -message DeleteJobResponse {} - -message DeleteJobsByPrefixRequestAlpha1 { optional string name_prefix = 1; } -message DeleteJobsByPrefixResponseAlpha1 {} - -message ListJobsRequestAlpha1 {} -message ListJobsResponseAlpha1 { repeated Job jobs = 1; } -```` - -Failure policy (from `src/proto/dapr/proto/common/v1/common.proto`): - -````proto -message JobFailurePolicy { - oneof policy { - JobFailurePolicyDrop drop = 1; - JobFailurePolicyConstant constant = 2; - } -} -message JobFailurePolicyDrop {} -message JobFailurePolicyConstant { - google.protobuf.Duration interval = 1; - optional uint32 max_retries = 2; -} -```` - ---- - -### Current state in the JS SDK - -| Area | Status | -|------|--------| -| Proto definitions (`src/proto/dapr/proto/runtime/v1/jobs.proto`) | ✅ Present | -| ConnectRPC bindings (`dapr_connect.{js,d.ts}`) | ✅ Generated | -| Server stub `onJobEventAlpha1` in `GRPCServerImpl.ts` | ⚠️ No-op (returns empty response) | -| Client interface for Jobs | ❌ Missing | -| Client implementation (gRPC) | ❌ Missing | -| Client implementation (HTTP) | ❌ Missing | -| User-facing TypeScript types | ❌ Missing | -| Job event handler registration on `DaprServer` | ❌ Missing | -| Tests | ❌ Missing | -| Exports from package index | ❌ Missing | - ---- - -### Proposed implementation scope - -#### 1. User-facing types (`src/types/jobs/`) - -````typescript -export interface DaprJob { - name: string; - schedule?: string; - repeats?: number; - dueTime?: string; - ttl?: string; - data?: unknown; - failurePolicy?: JobFailurePolicy; -} - -export type JobFailurePolicy = - | { type: "drop" } - | { type: "constant"; interval: string; maxRetries?: number }; - -export interface ScheduleJobOptions { - job: DaprJob; - overwrite?: boolean; -} -```` - -#### 2. Client interface (`src/interfaces/Client/IClientJobs.ts`) - -````typescript -export interface IClientJobs { - schedule(options: ScheduleJobOptions): Promise; - get(name: string): Promise; - delete(name: string): Promise; - deleteByPrefix(namePrefix?: string): Promise; - list(): Promise; -} -```` - -#### 3. gRPC client implementation (`src/implementation/Client/GRPCClient/jobs.ts`) - -Map user-facing types ↔ proto schemas and call: -- `client.scheduleJobAlpha1()` -- `client.getJobAlpha1()` -- `client.deleteJobAlpha1()` -- `client.deleteJobsByPrefixAlpha1()` -- `client.listJobsAlpha1()` - -Handle `google.protobuf.Any` serialization for `data` transparently. - -#### 4. HTTP client implementation (`src/implementation/Client/HTTPClient/jobs.ts`) - -Endpoints: -- `POST /v1.0-alpha1/jobs/` — schedule -- `GET /v1.0-alpha1/jobs/` — get -- `DELETE /v1.0-alpha1/jobs/` — delete - -#### 5. DaprClient integration - -Expose a `jobs` property on `DaprClient`: - -````typescript -const client = new DaprClient(); -await client.jobs.schedule({ job: { name: "my-job", schedule: "@every 5m" } }); -const job = await client.jobs.get("my-job"); -const all = await client.jobs.list(); -await client.jobs.delete("my-job"); -await client.jobs.deleteByPrefix("my-"); -```` - -#### 6. Server-side: job handler registration - -Allow registering named handlers on `DaprServer`: - -````typescript -const server = new DaprServer(); - -server.jobs.on("send-reminder", async (event) => { - const payload = event.data; - console.log("Triggered:", payload); -}); - -await server.start(); -```` - -#### 7. Implement `onJobEventAlpha1` routing - -Update `GRPCServerImpl.ts` to route incoming `JobEventRequest` messages to the -appropriate registered handler by job name. - -#### 8. Exports - -Export all new types and interfaces from `src/index.ts`. - -#### 9. Tests - -- Unit tests for type ↔ proto mapping (both directions) -- E2E test with a scheduler component if testcontainers supports it - ---- - -### Target usage examples - -**Scheduling a job:** - -````typescript -import { DaprClient } from "@dapr/dapr"; - -const client = new DaprClient(); - -await client.jobs.schedule({ - job: { - name: "send-report", - schedule: "0 0 9 * * *", // 9 AM daily - data: { reportType: "weekly", recipients: ["team@example.com"] }, - failurePolicy: { type: "constant", interval: "30s", maxRetries: 3 }, - }, - overwrite: true, -}); -```` - -**Receiving a triggered job:** - -````typescript -import { DaprServer } from "@dapr/dapr"; - -const server = new DaprServer(); - -server.jobs.on("send-report", async (event) => { - const { reportType, recipients } = event.data; - await generateAndSendReport(reportType, recipients); -}); - -await server.start(); -```` - ---- - -### References - -| Resource | Location | -|----------|----------| -| Proto messages | `src/proto/dapr/proto/runtime/v1/jobs.proto` | -| Proto RPCs | `src/proto/dapr/proto/runtime/v1/dapr.proto:235-246` | -| App callback | `src/proto/dapr/proto/runtime/v1/appcallback.proto:70` | -| Failure policy | `src/proto/dapr/proto/common/v1/common.proto:163-178` | -| ConnectRPC bindings | `src/proto/dapr/proto/runtime/v1/dapr_connect.{js,d.ts}` | -| Existing server stub | `src/implementation/Server/GRPCServer/GRPCServerImpl.ts:396` | -| Dapr docs | https://docs.dapr.io/reference/api/jobs_api/ | - ---- - -### Acceptance criteria - -- [ ] `DaprClient` exposes `client.jobs` with `schedule()`, `get()`, `delete()`, `deleteByPrefix()`, `list()` -- [ ] Both gRPC and HTTP transports are implemented -- [ ] `DaprServer` supports registering job event handlers by name -- [ ] `onJobEventAlpha1` in `GRPCServerImpl` routes events to registered handlers -- [ ] All proto fields are represented in user-facing types (including `failure_policy`, `overwrite`, `data`) -- [ ] `google.protobuf.Any` payload is serialized/deserialized transparently -- [ ] Unit tests cover type mapping between user types and proto schemas -- [ ] New types/interfaces are exported from the package index diff --git a/src/implementation/Client/GRPCClient/conversation.ts b/src/implementation/Client/GRPCClient/conversation.ts index 5abe415a..f2d9ac7a 100644 --- a/src/implementation/Client/GRPCClient/conversation.ts +++ b/src/implementation/Client/GRPCClient/conversation.ts @@ -37,7 +37,7 @@ import type { ConversationResponseAlpha2, } from "../../../proto/dapr/proto/runtime/v1/dapr_pb"; import { - ConversationRequest, + ConversationOptions, ConversationResponse, ConversationInput, ConversationMessage, @@ -55,21 +55,25 @@ export default class GRPCClientConversation implements IClientConversation { this.client = client; } - async converse(request: ConversationRequest): Promise { + async converse( + conversationComponentName: string, + inputs: ConversationInput[], + options?: ConversationOptions, + ): Promise { const client = await this.client.getClient(); - const protoInputs = request.inputs.map((input) => this.buildInput(input)); - const protoTools = (request.tools ?? []).map((tool) => this.buildTool(tool)); + const protoInputs = inputs.map((input) => this.buildInput(input)); + const protoTools = (options?.tools ?? []).map((tool) => this.buildTool(tool)); const protoRequest = create(ConversationRequestAlpha2Schema, { - name: request.name, - contextId: request.contextId, + name: conversationComponentName, + contextId: options?.contextId, inputs: protoInputs, - metadata: request.metadata ?? {}, - scrubPii: request.scrubPii, - temperature: request.temperature, + metadata: options?.metadata ?? {}, + scrubPii: options?.scrubPii, + temperature: options?.temperature, tools: protoTools, - toolChoice: request.toolChoice, + toolChoice: options?.toolChoice, }); const res: ConversationResponseAlpha2 = await client.converseAlpha2(protoRequest); diff --git a/src/implementation/Client/HTTPClient/conversation.ts b/src/implementation/Client/HTTPClient/conversation.ts index b52156bb..dffcedf2 100644 --- a/src/implementation/Client/HTTPClient/conversation.ts +++ b/src/implementation/Client/HTTPClient/conversation.ts @@ -14,8 +14,9 @@ limitations under the License. import HTTPClient from "./HTTPClient"; import IClientConversation from "../../../interfaces/Client/IClientConversation"; import { - ConversationRequest, + ConversationOptions, ConversationResponse, + ConversationInput, ConversationResult, ConversationResultChoice, ConversationResultToolCall, @@ -32,13 +33,17 @@ export default class HTTPClientConversation implements IClientConversation { this.logger = new Logger("HTTPClient", "Conversation", client.options.logger); } - async converse(request: ConversationRequest): Promise { - const body = this.buildRequestBody(request); + 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/${request.name}/converse`, + `/conversation/${conversationComponentName}/converse`, { method: "POST", body, @@ -53,14 +58,14 @@ export default class HTTPClientConversation implements IClientConversation { } } - private buildRequestBody(request: ConversationRequest): object { + private buildRequestBody(inputs: ConversationInput[], options?: ConversationOptions): object { const body: Record = {}; - if (request.contextId) { - body.contextID = request.contextId; + if (options?.contextId) { + body.contextID = options.contextId; } - body.inputs = request.inputs.map((input) => ({ + body.inputs = inputs.map((input) => ({ messages: input.messages.map((msg) => { switch (msg.role) { case "developer": @@ -87,20 +92,20 @@ export default class HTTPClientConversation implements IClientConversation { scrubPII: input.scrubPii, })); - if (request.metadata) { - body.metadata = request.metadata; + if (options?.metadata) { + body.metadata = options.metadata; } - if (request.scrubPii !== undefined) { - body.scrubPII = request.scrubPii; + if (options?.scrubPii !== undefined) { + body.scrubPII = options.scrubPii; } - if (request.temperature !== undefined) { - body.temperature = request.temperature; + if (options?.temperature !== undefined) { + body.temperature = options.temperature; } - if (request.tools?.length) { - body.tools = request.tools.map((tool) => ({ + if (options?.tools?.length) { + body.tools = options.tools.map((tool) => ({ function: { name: tool.function.name, description: tool.function.description, @@ -109,8 +114,16 @@ export default class HTTPClientConversation implements IClientConversation { })); } - if (request.toolChoice) { - body.toolChoice = request.toolChoice; + if (options?.toolChoice) { + body.toolChoice = options.toolChoice; + } + + if (options?.promptCacheRetention) { + body.promptCacheRetention = options.promptCacheRetention; + } + + if (options?.responseFormat) { + body.responseFormat = options.responseFormat; } return body; diff --git a/src/index.ts b/src/index.ts index fd1ebbf5..f61b7921 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,7 +55,7 @@ import { } from "./workflow/runtime/WorkflowRuntimeStatus"; import { - ConversationRequest, + ConversationOptions, ConversationResponse, ConversationInput, ConversationMessage, @@ -105,7 +105,7 @@ export { WorkflowRuntimeStatus, fromOrchestrationStatus, toOrchestrationStatus, - ConversationRequest, + ConversationOptions, ConversationResponse, ConversationInput, ConversationMessage, diff --git a/src/interfaces/Client/IClientConversation.ts b/src/interfaces/Client/IClientConversation.ts index 13754053..2228d632 100644 --- a/src/interfaces/Client/IClientConversation.ts +++ b/src/interfaces/Client/IClientConversation.ts @@ -11,13 +11,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ConversationRequest, ConversationResponse } from "../../types/conversation/Conversation.type"; +import { ConversationInput, ConversationOptions, ConversationResponse } from "../../types/conversation/Conversation.type"; export default interface IClientConversation { /** * Converse with a LLM service using the Alpha2 Conversation API. - * @param request - The conversation request parameters + * @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(request: ConversationRequest): Promise; + converse( + conversationComponentName: string, + inputs: ConversationInput[], + options?: ConversationOptions, + ): Promise; } diff --git a/src/types/conversation/Conversation.type.ts b/src/types/conversation/Conversation.type.ts index 93924794..891ee6ba 100644 --- a/src/types/conversation/Conversation.type.ts +++ b/src/types/conversation/Conversation.type.ts @@ -111,15 +111,11 @@ export interface ConversationTool { } /** - * Request for the Alpha2 Conversation API. + * Options for the Alpha2 Conversation API. */ -export interface ConversationRequest { - /** The name of the Conversation component */ - name: string; +export interface ConversationOptions { /** The ID of an existing chat context */ contextId?: string; - /** Inputs for the conversation */ - inputs: ConversationInput[]; /** Metadata key-value pairs */ metadata?: Record; /** Scrub PII data that comes back from the LLM */ @@ -130,6 +126,10 @@ export interface ConversationRequest { 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; } /** From b56e5a2d8df182e1bea1f6c6d4c79d9eaf72402f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 02:40:45 +0000 Subject: [PATCH 5/7] Add unit tests for Conversation API (HTTP and gRPC) Add comprehensive tests for HTTPClientConversation and GRPCClientConversation covering all message roles, options, request building, response mapping, tool calls, edge cases, and error handling. 68 tests total with 100% statement/function coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com> --- test/unit/protocols/grpc/conversation.test.ts | 672 ++++++++++++++++++ test/unit/protocols/http/conversation.test.ts | 598 ++++++++++++++++ 2 files changed, 1270 insertions(+) create mode 100644 test/unit/protocols/grpc/conversation.test.ts create mode 100644 test/unit/protocols/http/conversation.test.ts 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); + }); + }); +}); From 23c0044d167a9cfd3ce79577bf9209a8f4a84f48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 08:18:39 +0000 Subject: [PATCH 6/7] test: add Testcontainers integration tests for Conversation API (HTTP + gRPC) Agent-Logs-Url: https://github.com/dapr/js-sdk/sessions/974b00f8-cad1-4955-bf91-95fd1cc4c8a0 Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com> --- package.json | 3 +- test/e2e/common/conversation.test.ts | 219 +++++++++++++++++++++++++++ test/e2e/helpers/containers.ts | 5 + 3 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 test/e2e/common/conversation.test.ts 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/test/e2e/common/conversation.test.ts b/test/e2e/common/conversation.test.ts new file mode 100644 index 00000000..235a207c --- /dev/null +++ b/test/e2e/common/conversation.test.ts @@ -0,0 +1,219 @@ +/* +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 { + DAPR_TEST_PLACEMENT_IMAGE, + DAPR_TEST_SCHEDULER_IMAGE, + 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", []); From b703c27571097be3b146440a98629e4db5c4d737 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 08:19:17 +0000 Subject: [PATCH 7/7] fix: remove unused imports in conversation integration test Agent-Logs-Url: https://github.com/dapr/js-sdk/sessions/974b00f8-cad1-4955-bf91-95fd1cc4c8a0 Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com> --- test/e2e/common/conversation.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/e2e/common/conversation.test.ts b/test/e2e/common/conversation.test.ts index 235a207c..ef4d0be7 100644 --- a/test/e2e/common/conversation.test.ts +++ b/test/e2e/common/conversation.test.ts @@ -20,8 +20,6 @@ import { ConversationResponse, } from "../../../src/types/conversation/Conversation.type"; import { - DAPR_TEST_PLACEMENT_IMAGE, - DAPR_TEST_SCHEDULER_IMAGE, buildConversationEchoComponent, runWithCleanupErrorSuppression, } from "../helpers/containers";