From 5474b243cc23d39ed8f4d7848cc8ff84e0d135e2 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Wed, 10 Jun 2026 16:37:08 -0400 Subject: [PATCH 01/15] feat: allow to emit end exchange event from client for stopping response --- docs/oauth-scopes.md | 1 + .../conversations/conversations.models.ts | 7 ++- .../conversations/exchanges.models.ts | 34 ++++++++++++- .../conversations/exchanges.types.ts | 2 + .../conversations/types/core.types.ts | 4 ++ .../conversations/exchanges.ts | 27 +++++++++- .../endpoints/conversational-agent.ts | 4 +- .../conversations.test.ts | 37 ++++++++++++++ .../conversational-agent/exchanges.test.ts | 51 +++++++++++++++++++ 9 files changed, 163 insertions(+), 4 deletions(-) diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index d614b4929..0fd874ede 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -159,6 +159,7 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `getAll()` | `OR.Execution` or `OR.Execution.Read`, `OR.Jobs` or `OR.Jobs.Read` | | `getById()` | `OR.Execution` or `OR.Execution.Read`, `OR.Jobs` or `OR.Jobs.Read` | | `createFeedback()` | `OR.Execution`, `OR.Jobs`, `Traces.Api` | +| `end()` | `OR.Jobs` or `OR.Jobs.Write` | ### Messages diff --git a/src/models/conversational-agent/conversations/conversations.models.ts b/src/models/conversational-agent/conversations/conversations.models.ts index 65772017e..182e77442 100644 --- a/src/models/conversational-agent/conversations/conversations.models.ts +++ b/src/models/conversational-agent/conversations/conversations.models.ts @@ -18,7 +18,7 @@ import type { ConversationAttachmentCreateResponse } from './conversations.types'; import type { ExchangeServiceModel, ConversationExchangeServiceModel } from './exchanges.models'; -import type { ExchangeGetByIdOptions, CreateFeedbackOptions } from './exchanges.types'; +import type { ExchangeGetByIdOptions, CreateFeedbackOptions, ExchangeEndResponse } from './exchanges.types'; import type { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '@/utils/pagination'; import type { ConnectionStatus, ConnectionStatusChangedHandler } from '@/core/websocket'; import type { SessionStream } from './types/events/session.types'; @@ -549,6 +549,11 @@ function createConversationMethods( if (!conversationData.id) throw new Error('Conversation ID is undefined'); if (!exchangeService) throw new Error('Exchange methods are not available.'); return exchangeService.createFeedback(conversationData.id, exchangeId, options); + }, + end(exchangeId: string): Promise { + if (!conversationData.id) throw new Error('Conversation ID is undefined'); + if (!exchangeService) throw new Error('Exchange methods are not available.'); + return exchangeService.end(conversationData.id, exchangeId); } }, diff --git a/src/models/conversational-agent/conversations/exchanges.models.ts b/src/models/conversational-agent/conversations/exchanges.models.ts index 57e663d09..1254e7e0c 100644 --- a/src/models/conversational-agent/conversations/exchanges.models.ts +++ b/src/models/conversational-agent/conversations/exchanges.models.ts @@ -7,7 +7,8 @@ import type { ExchangeGetByIdOptions, CreateFeedbackOptions, FeedbackCreateResponse, - ExchangeGetResponse + ExchangeGetResponse, + ExchangeEndResponse } from './exchanges.types'; import type { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '@/utils/pagination'; @@ -107,6 +108,23 @@ export interface ExchangeServiceModel { exchangeId: string, options: CreateFeedbackOptions ): Promise; + + /** + * Ends an exchange, setting the endedAt timestamp and stopping any running agent job + * + * @param conversationId - The conversation containing the exchange + * @param exchangeId - The exchange to end + * @returns Promise resolving to {@link ExchangeEndResponse} + * @example + * ```typescript + * const endedExchange = await exchanges.end(conversationId, exchangeId); + * console.log(`Exchange ended at: ${endedExchange.endedAt}`); + * ``` + */ + end( + conversationId: string, + exchangeId: string + ): Promise; } /** @@ -181,4 +199,18 @@ export interface ConversationExchangeServiceModel { exchangeId: string, options: CreateFeedbackOptions ): Promise; + + /** + * Ends an exchange in this conversation + * + * @param exchangeId - The exchange to end + * @returns Promise resolving to the ended exchange + * + * @example + * ```typescript + * const endedExchange = await conversation.exchanges.end(exchangeId); + * console.log(`Exchange ended at: ${endedExchange.endedAt}`); + * ``` + */ + end(exchangeId: string): Promise; } diff --git a/src/models/conversational-agent/conversations/exchanges.types.ts b/src/models/conversational-agent/conversations/exchanges.types.ts index 6cf7a61c0..915c09fdf 100644 --- a/src/models/conversational-agent/conversations/exchanges.types.ts +++ b/src/models/conversational-agent/conversations/exchanges.types.ts @@ -65,6 +65,8 @@ export interface ExchangeGetByIdOptions { messageSort?: SortOrder; } +export type ExchangeEndResponse = ExchangeGetResponse; + // ==================== Feedback Types ==================== export interface CreateFeedbackOptions { diff --git a/src/models/conversational-agent/conversations/types/core.types.ts b/src/models/conversational-agent/conversations/types/core.types.ts index 642cc8fc6..1845d8b36 100644 --- a/src/models/conversational-agent/conversations/types/core.types.ts +++ b/src/models/conversational-agent/conversations/types/core.types.ts @@ -298,6 +298,10 @@ export interface Exchange { * Span identifier for distributed tracing. */ spanId?: string; + /** + * Timestamp indicating when the exchange ended, if it has ended. + */ + endedAt?: string; /** * The optional feedback rating given by the user. */ diff --git a/src/services/conversational-agent/conversations/exchanges.ts b/src/services/conversational-agent/conversations/exchanges.ts index d65bd0aed..3ebbfccf8 100644 --- a/src/services/conversational-agent/conversations/exchanges.ts +++ b/src/services/conversational-agent/conversations/exchanges.ts @@ -20,7 +20,8 @@ import type { FeedbackCreateResponse, ExchangeGetAllOptions, ExchangeGetByIdOptions, - ExchangeGetResponse + ExchangeGetResponse, + ExchangeEndResponse } from '@/models/conversational-agent'; // Utils @@ -201,4 +202,28 @@ export class ExchangeService extends BaseService implements ExchangeServiceModel ); return response.data; } + + /** + * Ends an exchange, setting the endedAt timestamp and stopping any running agent job + * + * @param conversationId - The conversation containing the exchange + * @param exchangeId - The exchange to end + * @returns Promise resolving to {@link ExchangeEndResponse} + * + * @example + * ```typescript + * const endedExchange = await exchanges.end(conversationId, exchangeId); + * console.log(`Exchange ended at: ${endedExchange.endedAt}`); + * ``` + */ + @track('ConversationalAgent.Exchanges.End') + async end( + conversationId: string, + exchangeId: string + ): Promise { + const response = await this.post( + EXCHANGE_ENDPOINTS.END(conversationId, exchangeId) + ); + return transformExchange(response.data); + } } diff --git a/src/utils/constants/endpoints/conversational-agent.ts b/src/utils/constants/endpoints/conversational-agent.ts index a8a6af960..ed7ed15a2 100644 --- a/src/utils/constants/endpoints/conversational-agent.ts +++ b/src/utils/constants/endpoints/conversational-agent.ts @@ -26,7 +26,9 @@ export const EXCHANGE_ENDPOINTS = { GET: (conversationId: string, exchangeId: string) => `${AUTOPILOT_BASE}/api/${API_VERSION}/conversation/${conversationId}/exchange/${exchangeId}`, CREATE_FEEDBACK: (conversationId: string, exchangeId: string) => - `${AUTOPILOT_BASE}/api/${API_VERSION}/conversation/${conversationId}/exchange/${exchangeId}/feedback` + `${AUTOPILOT_BASE}/api/${API_VERSION}/conversation/${conversationId}/exchange/${exchangeId}/feedback`, + END: (conversationId: string, exchangeId: string) => + `${AUTOPILOT_BASE}/api/${API_VERSION}/conversation/${conversationId}/exchange/${exchangeId}/end` } as const; /** diff --git a/tests/unit/models/conversational-agent/conversations.test.ts b/tests/unit/models/conversational-agent/conversations.test.ts index 5d10717a7..1be471b2c 100644 --- a/tests/unit/models/conversational-agent/conversations.test.ts +++ b/tests/unit/models/conversational-agent/conversations.test.ts @@ -59,6 +59,7 @@ describe('Conversation Models', () => { getAll: vi.fn(), getById: vi.fn(), createFeedback: vi.fn(), + end: vi.fn(), } as any; }); @@ -465,6 +466,42 @@ describe('Conversation Models', () => { ).toThrow('Exchange methods are not available'); }); }); + + describe('exchanges.end()', () => { + it('should delegate to exchangeService.end', async () => { + const mockResponse = {}; + (mockExchangeService.end as any).mockResolvedValue(mockResponse); + + const data = createMockConversationData(); + const conversation = createConversationWithMethods(data, mockService, mockSessionMethods, mockExchangeService); + + const result = await conversation.exchanges.end(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); + + expect(mockExchangeService.end).toHaveBeenCalledWith( + CONVERSATIONAL_AGENT_TEST_CONSTANTS.CONVERSATION_ID, + CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID + ); + expect(result).toEqual(mockResponse); + }); + + it('should throw error if conversation id is undefined', () => { + const data = createMockConversationData({ id: undefined }); + const conversation = createConversationWithMethods(data, mockService, mockSessionMethods, mockExchangeService); + + expect( + () => conversation.exchanges.end(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID) + ).toThrow('Conversation ID is undefined'); + }); + + it('should throw error if exchange service is not available', () => { + const data = createMockConversationData(); + const conversation = createConversationWithMethods(data, mockService, mockSessionMethods); + + expect( + () => conversation.exchanges.end(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID) + ).toThrow('Exchange methods are not available'); + }); + }); }); describe('data isolation', () => { diff --git a/tests/unit/services/conversational-agent/exchanges.test.ts b/tests/unit/services/conversational-agent/exchanges.test.ts index fdf6e55de..450cb0624 100644 --- a/tests/unit/services/conversational-agent/exchanges.test.ts +++ b/tests/unit/services/conversational-agent/exchanges.test.ts @@ -422,4 +422,55 @@ describe('ExchangeService Unit Tests', () => { ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); + + describe('end', () => { + it('should end an exchange and return transformed result', async () => { + const mockExchange = createMockRawExchange(); + mockApiClient.post.mockResolvedValue(mockExchange); + + const result = await exchanges.end( + CONVERSATIONAL_AGENT_TEST_CONSTANTS.CONVERSATION_ID, + CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID + ); + + expect(result).toBeDefined(); + expect(result.id).toBe(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); + expect(result.exchangeId).toBe(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); + + expect(mockApiClient.post).toHaveBeenCalledWith( + EXCHANGE_ENDPOINTS.END( + CONVERSATIONAL_AGENT_TEST_CONSTANTS.CONVERSATION_ID, + CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID + ), + undefined, + expect.any(Object) + ); + }); + + it('should transform messages within the ended exchange', async () => { + const mockExchange = createMockRawExchange(); + mockApiClient.post.mockResolvedValue(mockExchange); + + const result = await exchanges.end( + CONVERSATIONAL_AGENT_TEST_CONSTANTS.CONVERSATION_ID, + CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[1].role).toBe('assistant'); + }); + + it('should handle API errors', async () => { + const error = createMockError(TEST_CONSTANTS.ERROR_MESSAGE); + mockApiClient.post.mockRejectedValue(error); + + await expect( + exchanges.end( + CONVERSATIONAL_AGENT_TEST_CONSTANTS.CONVERSATION_ID, + CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID + ) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); }); From ec7aee8da5c813ad6f612bbd4bfd06395e72d596 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Fri, 12 Jun 2026 11:28:31 -0400 Subject: [PATCH 02/15] test: update test expect end --- tests/unit/models/conversational-agent/conversations.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/models/conversational-agent/conversations.test.ts b/tests/unit/models/conversational-agent/conversations.test.ts index 1be471b2c..faa234208 100644 --- a/tests/unit/models/conversational-agent/conversations.test.ts +++ b/tests/unit/models/conversational-agent/conversations.test.ts @@ -96,6 +96,7 @@ describe('Conversation Models', () => { expect(typeof conversation.exchanges.getAll).toBe('function'); expect(typeof conversation.exchanges.getById).toBe('function'); expect(typeof conversation.exchanges.createFeedback).toBe('function'); + expect(typeof conversation.exchanges.end).toBe('function'); }); }); From f09a314cb53e69ca385ede2bf67f83da43761702 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Fri, 12 Jun 2026 11:42:18 -0400 Subject: [PATCH 03/15] chore: update from claude review --- .../conversational-agent/conversations/exchanges.models.ts | 2 +- tests/unit/models/conversational-agent/conversations.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/conversational-agent/conversations/exchanges.models.ts b/src/models/conversational-agent/conversations/exchanges.models.ts index c297e9e0e..95edadeb0 100644 --- a/src/models/conversational-agent/conversations/exchanges.models.ts +++ b/src/models/conversational-agent/conversations/exchanges.models.ts @@ -242,7 +242,7 @@ export interface ConversationExchangeServiceModel { * Ends an exchange in this conversation * * @param exchangeId - The exchange to end - * @returns Promise resolving to the ended exchange + * @returns Promise resolving to {@link ExchangeEndResponse} * * @example * ```typescript diff --git a/tests/unit/models/conversational-agent/conversations.test.ts b/tests/unit/models/conversational-agent/conversations.test.ts index faa234208..770595eb0 100644 --- a/tests/unit/models/conversational-agent/conversations.test.ts +++ b/tests/unit/models/conversational-agent/conversations.test.ts @@ -60,7 +60,7 @@ describe('Conversation Models', () => { getById: vi.fn(), createFeedback: vi.fn(), end: vi.fn(), - } as any; + }; }); afterEach(() => { From 9493f8b566ad820ab62e85ba12d2c3553021d4f2 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Mon, 15 Jun 2026 10:44:23 -0400 Subject: [PATCH 04/15] chore: add internal flag --- .../conversational-agent/conversations/exchanges.models.ts | 2 ++ .../conversational-agent/conversations/exchanges.types.ts | 1 + .../conversational-agent/conversations/types/core.types.ts | 1 + src/services/conversational-agent/conversations/exchanges.ts | 1 + 4 files changed, 5 insertions(+) diff --git a/src/models/conversational-agent/conversations/exchanges.models.ts b/src/models/conversational-agent/conversations/exchanges.models.ts index 95edadeb0..730ef33d2 100644 --- a/src/models/conversational-agent/conversations/exchanges.models.ts +++ b/src/models/conversational-agent/conversations/exchanges.models.ts @@ -129,6 +129,7 @@ export interface ExchangeServiceModel { /** * Ends an exchange, setting the endedAt timestamp and stopping any running agent job * + * @internal * @param conversationId - The conversation containing the exchange * @param exchangeId - The exchange to end * @returns Promise resolving to {@link ExchangeEndResponse} @@ -241,6 +242,7 @@ export interface ConversationExchangeServiceModel { /** * Ends an exchange in this conversation * + * @internal * @param exchangeId - The exchange to end * @returns Promise resolving to {@link ExchangeEndResponse} * diff --git a/src/models/conversational-agent/conversations/exchanges.types.ts b/src/models/conversational-agent/conversations/exchanges.types.ts index 915c09fdf..79085fb85 100644 --- a/src/models/conversational-agent/conversations/exchanges.types.ts +++ b/src/models/conversational-agent/conversations/exchanges.types.ts @@ -65,6 +65,7 @@ export interface ExchangeGetByIdOptions { messageSort?: SortOrder; } +/** @internal */ export type ExchangeEndResponse = ExchangeGetResponse; // ==================== Feedback Types ==================== diff --git a/src/models/conversational-agent/conversations/types/core.types.ts b/src/models/conversational-agent/conversations/types/core.types.ts index 14b80866a..837cc6e63 100644 --- a/src/models/conversational-agent/conversations/types/core.types.ts +++ b/src/models/conversational-agent/conversations/types/core.types.ts @@ -300,6 +300,7 @@ export interface Exchange { spanId?: string; /** * Timestamp indicating when the exchange ended, if it has ended. + * @internal */ endedAt?: string; /** diff --git a/src/services/conversational-agent/conversations/exchanges.ts b/src/services/conversational-agent/conversations/exchanges.ts index d3f7267d2..80c3d0732 100644 --- a/src/services/conversational-agent/conversations/exchanges.ts +++ b/src/services/conversational-agent/conversations/exchanges.ts @@ -222,6 +222,7 @@ export class ExchangeService extends BaseService implements ExchangeServiceModel /** * Ends an exchange, setting the endedAt timestamp and stopping any running agent job * + * @internal * @param conversationId - The conversation containing the exchange * @param exchangeId - The exchange to end * @returns Promise resolving to {@link ExchangeEndResponse} From 2c676a0a6d7385c6f4fe59bd18baa6372f2673cd Mon Sep 17 00:00:00 2001 From: Norman Le Date: Mon, 15 Jun 2026 11:35:41 -0400 Subject: [PATCH 05/15] chore: remove internal from internal types --- src/models/conversational-agent/conversations/exchanges.types.ts | 1 - .../conversational-agent/conversations/types/core.types.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/models/conversational-agent/conversations/exchanges.types.ts b/src/models/conversational-agent/conversations/exchanges.types.ts index 79085fb85..915c09fdf 100644 --- a/src/models/conversational-agent/conversations/exchanges.types.ts +++ b/src/models/conversational-agent/conversations/exchanges.types.ts @@ -65,7 +65,6 @@ export interface ExchangeGetByIdOptions { messageSort?: SortOrder; } -/** @internal */ export type ExchangeEndResponse = ExchangeGetResponse; // ==================== Feedback Types ==================== diff --git a/src/models/conversational-agent/conversations/types/core.types.ts b/src/models/conversational-agent/conversations/types/core.types.ts index 837cc6e63..14b80866a 100644 --- a/src/models/conversational-agent/conversations/types/core.types.ts +++ b/src/models/conversational-agent/conversations/types/core.types.ts @@ -300,7 +300,6 @@ export interface Exchange { spanId?: string; /** * Timestamp indicating when the exchange ended, if it has ended. - * @internal */ endedAt?: string; /** From 6234e8dbd219f7512ca38421eb9416b2ce29a1ab Mon Sep 17 00:00:00 2001 From: Norman Le Date: Wed, 17 Jun 2026 10:53:22 -0400 Subject: [PATCH 06/15] chore: change to experimental flag --- .../conversational-agent/conversations/exchanges.models.ts | 4 ++-- src/services/conversational-agent/conversations/exchanges.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/models/conversational-agent/conversations/exchanges.models.ts b/src/models/conversational-agent/conversations/exchanges.models.ts index 730ef33d2..e20d48ffe 100644 --- a/src/models/conversational-agent/conversations/exchanges.models.ts +++ b/src/models/conversational-agent/conversations/exchanges.models.ts @@ -129,7 +129,7 @@ export interface ExchangeServiceModel { /** * Ends an exchange, setting the endedAt timestamp and stopping any running agent job * - * @internal + * @experimental * @param conversationId - The conversation containing the exchange * @param exchangeId - The exchange to end * @returns Promise resolving to {@link ExchangeEndResponse} @@ -242,7 +242,7 @@ export interface ConversationExchangeServiceModel { /** * Ends an exchange in this conversation * - * @internal + * @experimental * @param exchangeId - The exchange to end * @returns Promise resolving to {@link ExchangeEndResponse} * diff --git a/src/services/conversational-agent/conversations/exchanges.ts b/src/services/conversational-agent/conversations/exchanges.ts index 80c3d0732..106dbb709 100644 --- a/src/services/conversational-agent/conversations/exchanges.ts +++ b/src/services/conversational-agent/conversations/exchanges.ts @@ -222,7 +222,7 @@ export class ExchangeService extends BaseService implements ExchangeServiceModel /** * Ends an exchange, setting the endedAt timestamp and stopping any running agent job * - * @internal + * @experimental * @param conversationId - The conversation containing the exchange * @param exchangeId - The exchange to end * @returns Promise resolving to {@link ExchangeEndResponse} From 678b03b8408c32d451968ff1dab2591fd6616008 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Wed, 17 Jun 2026 11:17:57 -0400 Subject: [PATCH 07/15] test: update end exchange test to check endedAt prop --- tests/unit/services/conversational-agent/exchanges.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/services/conversational-agent/exchanges.test.ts b/tests/unit/services/conversational-agent/exchanges.test.ts index aa3064a27..4bdd3143a 100644 --- a/tests/unit/services/conversational-agent/exchanges.test.ts +++ b/tests/unit/services/conversational-agent/exchanges.test.ts @@ -444,7 +444,7 @@ describe('ExchangeService Unit Tests', () => { describe('end', () => { it('should end an exchange and return transformed result', async () => { - const mockExchange = createMockRawExchange(); + const mockExchange = createMockRawExchange({ endedAt: '2024-01-01T12:00:00.000Z' }); mockApiClient.post.mockResolvedValue(mockExchange); const result = await exchanges.end( @@ -455,6 +455,7 @@ describe('ExchangeService Unit Tests', () => { expect(result).toBeDefined(); expect(result.id).toBe(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); expect(result.exchangeId).toBe(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); + expect(result.endedAt).toBe('2024-01-01T12:00:00.000Z'); expect(mockApiClient.post).toHaveBeenCalledWith( EXCHANGE_ENDPOINTS.END( From ab4d4b1affb09fb37e0601efe018906e22b9cd78 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Wed, 17 Jun 2026 11:25:02 -0400 Subject: [PATCH 08/15] refactor: rename endedAt to endedTime for consistency --- src/models/conversational-agent/common.constants.ts | 3 ++- .../conversational-agent/conversations/exchanges.models.ts | 6 +++--- .../conversational-agent/conversations/types/core.types.ts | 2 +- .../conversational-agent/conversations/exchanges.ts | 4 ++-- tests/unit/services/conversational-agent/exchanges.test.ts | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/models/conversational-agent/common.constants.ts b/src/models/conversational-agent/common.constants.ts index e2a4585c3..40c22b239 100644 --- a/src/models/conversational-agent/common.constants.ts +++ b/src/models/conversational-agent/common.constants.ts @@ -8,5 +8,6 @@ */ export const CommonFieldMap: { [key: string]: string } = { createdAt: 'createdTime', - updatedAt: 'updatedTime' + updatedAt: 'updatedTime', + endedAt: 'endedTime' }; diff --git a/src/models/conversational-agent/conversations/exchanges.models.ts b/src/models/conversational-agent/conversations/exchanges.models.ts index e20d48ffe..43701eabe 100644 --- a/src/models/conversational-agent/conversations/exchanges.models.ts +++ b/src/models/conversational-agent/conversations/exchanges.models.ts @@ -127,7 +127,7 @@ export interface ExchangeServiceModel { ): Promise; /** - * Ends an exchange, setting the endedAt timestamp and stopping any running agent job + * Ends an exchange, setting the endedTime timestamp and stopping any running agent job * * @experimental * @param conversationId - The conversation containing the exchange @@ -136,7 +136,7 @@ export interface ExchangeServiceModel { * @example * ```typescript * const endedExchange = await exchanges.end(conversationId, exchangeId); - * console.log(`Exchange ended at: ${endedExchange.endedAt}`); + * console.log(`Exchange ended at: ${endedExchange.endedTime}`); * ``` */ end( @@ -249,7 +249,7 @@ export interface ConversationExchangeServiceModel { * @example * ```typescript * const endedExchange = await conversation.exchanges.end(exchangeId); - * console.log(`Exchange ended at: ${endedExchange.endedAt}`); + * console.log(`Exchange ended at: ${endedExchange.endedTime}`); * ``` */ end(exchangeId: string): Promise; diff --git a/src/models/conversational-agent/conversations/types/core.types.ts b/src/models/conversational-agent/conversations/types/core.types.ts index 14b80866a..f64818d7d 100644 --- a/src/models/conversational-agent/conversations/types/core.types.ts +++ b/src/models/conversational-agent/conversations/types/core.types.ts @@ -301,7 +301,7 @@ export interface Exchange { /** * Timestamp indicating when the exchange ended, if it has ended. */ - endedAt?: string; + endedTime?: string; /** * The optional feedback rating given by the user. */ diff --git a/src/services/conversational-agent/conversations/exchanges.ts b/src/services/conversational-agent/conversations/exchanges.ts index 106dbb709..60cc5cc83 100644 --- a/src/services/conversational-agent/conversations/exchanges.ts +++ b/src/services/conversational-agent/conversations/exchanges.ts @@ -220,7 +220,7 @@ export class ExchangeService extends BaseService implements ExchangeServiceModel } /** - * Ends an exchange, setting the endedAt timestamp and stopping any running agent job + * Ends an exchange, setting the endedTime timestamp and stopping any running agent job * * @experimental * @param conversationId - The conversation containing the exchange @@ -230,7 +230,7 @@ export class ExchangeService extends BaseService implements ExchangeServiceModel * @example * ```typescript * const endedExchange = await exchanges.end(conversationId, exchangeId); - * console.log(`Exchange ended at: ${endedExchange.endedAt}`); + * console.log(`Exchange ended at: ${endedExchange.endedTime}`); * ``` */ @track('ConversationalAgent.Exchanges.End') diff --git a/tests/unit/services/conversational-agent/exchanges.test.ts b/tests/unit/services/conversational-agent/exchanges.test.ts index 4bdd3143a..e7098f624 100644 --- a/tests/unit/services/conversational-agent/exchanges.test.ts +++ b/tests/unit/services/conversational-agent/exchanges.test.ts @@ -455,7 +455,7 @@ describe('ExchangeService Unit Tests', () => { expect(result).toBeDefined(); expect(result.id).toBe(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); expect(result.exchangeId).toBe(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); - expect(result.endedAt).toBe('2024-01-01T12:00:00.000Z'); + expect(result.endedTime).toBe('2024-01-01T12:00:00.000Z'); expect(mockApiClient.post).toHaveBeenCalledWith( EXCHANGE_ENDPOINTS.END( From eeaf5d6064209b0cae02fb628ed7c3ef8ae4ff49 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Wed, 17 Jun 2026 11:33:40 -0400 Subject: [PATCH 09/15] chore: update export for typedoc convention --- .../conversational-agent/conversations/exchanges.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/conversational-agent/conversations/exchanges.types.ts b/src/models/conversational-agent/conversations/exchanges.types.ts index 915c09fdf..98cc4f9ab 100644 --- a/src/models/conversational-agent/conversations/exchanges.types.ts +++ b/src/models/conversational-agent/conversations/exchanges.types.ts @@ -65,7 +65,7 @@ export interface ExchangeGetByIdOptions { messageSort?: SortOrder; } -export type ExchangeEndResponse = ExchangeGetResponse; +export interface ExchangeEndResponse extends ExchangeGetResponse {} // ==================== Feedback Types ==================== From fd699581b0da4a34a9723e2eebc370ce478e9249 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Wed, 17 Jun 2026 11:46:22 -0400 Subject: [PATCH 10/15] test: add test to make sure transformation assertion is undefined --- tests/unit/services/conversational-agent/exchanges.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/services/conversational-agent/exchanges.test.ts b/tests/unit/services/conversational-agent/exchanges.test.ts index e7098f624..3594a0fb6 100644 --- a/tests/unit/services/conversational-agent/exchanges.test.ts +++ b/tests/unit/services/conversational-agent/exchanges.test.ts @@ -456,6 +456,7 @@ describe('ExchangeService Unit Tests', () => { expect(result.id).toBe(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); expect(result.exchangeId).toBe(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); expect(result.endedTime).toBe('2024-01-01T12:00:00.000Z'); + expect((result as any).endedAt).toBeUndefined(); expect(mockApiClient.post).toHaveBeenCalledWith( EXCHANGE_ENDPOINTS.END( From 38e53522fbe32eb43beae14e7fdefc5be0314067 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Tue, 23 Jun 2026 14:10:47 -0400 Subject: [PATCH 11/15] revert: revert the branch changes --- docs/oauth-scopes.md | 1 - .../conversational-agent/common.constants.ts | 3 +- .../conversations/conversations.models.ts | 7 +-- .../conversations/exchanges.models.ts | 36 +------------ .../conversations/exchanges.types.ts | 2 - .../conversations/types/core.types.ts | 4 -- .../conversations/exchanges.ts | 28 +--------- .../endpoints/conversational-agent.ts | 4 +- .../conversations.test.ts | 40 +------------- .../conversational-agent/exchanges.test.ts | 53 ------------------- 10 files changed, 6 insertions(+), 172 deletions(-) diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 1b1e3b5d8..297b6c16d 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -159,7 +159,6 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `getAll()` | `OR.Execution` or `OR.Execution.Read`, `OR.Jobs` or `OR.Jobs.Read` | | `getById()` | `OR.Execution` or `OR.Execution.Read`, `OR.Jobs` or `OR.Jobs.Read` | | `createFeedback()` | `OR.Execution`, `OR.Jobs`, `Traces.Api` | -| `end()` | `OR.Jobs` or `OR.Jobs.Write` | ### Messages diff --git a/src/models/conversational-agent/common.constants.ts b/src/models/conversational-agent/common.constants.ts index 40c22b239..e2a4585c3 100644 --- a/src/models/conversational-agent/common.constants.ts +++ b/src/models/conversational-agent/common.constants.ts @@ -8,6 +8,5 @@ */ export const CommonFieldMap: { [key: string]: string } = { createdAt: 'createdTime', - updatedAt: 'updatedTime', - endedAt: 'endedTime' + updatedAt: 'updatedTime' }; diff --git a/src/models/conversational-agent/conversations/conversations.models.ts b/src/models/conversational-agent/conversations/conversations.models.ts index ca076ed89..fb6a83629 100644 --- a/src/models/conversational-agent/conversations/conversations.models.ts +++ b/src/models/conversational-agent/conversations/conversations.models.ts @@ -18,7 +18,7 @@ import type { ConversationAttachmentCreateResponse } from './conversations.types'; import type { ExchangeServiceModel, ConversationExchangeServiceModel } from './exchanges.models'; -import type { ExchangeGetByIdOptions, CreateFeedbackOptions, ExchangeEndResponse } from './exchanges.types'; +import type { ExchangeGetByIdOptions, CreateFeedbackOptions } from './exchanges.types'; import type { PaginatedResponse } from '@/utils/pagination'; import type { ConnectionStatus, ConnectionStatusChangedHandler } from '@/core/websocket'; import type { SessionStream } from './types/events/session.types'; @@ -559,11 +559,6 @@ function createConversationMethods( if (!conversationData.id) throw new Error('Conversation ID is undefined'); if (!exchangeService) throw new Error('Exchange methods are not available.'); return exchangeService.createFeedback(conversationData.id, exchangeId, options); - }, - end(exchangeId: string): Promise { - if (!conversationData.id) throw new Error('Conversation ID is undefined'); - if (!exchangeService) throw new Error('Exchange methods are not available.'); - return exchangeService.end(conversationData.id, exchangeId); } }, diff --git a/src/models/conversational-agent/conversations/exchanges.models.ts b/src/models/conversational-agent/conversations/exchanges.models.ts index 43701eabe..5b3556caa 100644 --- a/src/models/conversational-agent/conversations/exchanges.models.ts +++ b/src/models/conversational-agent/conversations/exchanges.models.ts @@ -7,8 +7,7 @@ import type { ExchangeGetByIdOptions, CreateFeedbackOptions, FeedbackCreateResponse, - ExchangeGetResponse, - ExchangeEndResponse + ExchangeGetResponse } from './exchanges.types'; import type { PaginatedResponse } from '@/utils/pagination'; @@ -125,24 +124,6 @@ export interface ExchangeServiceModel { exchangeId: string, options: CreateFeedbackOptions ): Promise; - - /** - * Ends an exchange, setting the endedTime timestamp and stopping any running agent job - * - * @experimental - * @param conversationId - The conversation containing the exchange - * @param exchangeId - The exchange to end - * @returns Promise resolving to {@link ExchangeEndResponse} - * @example - * ```typescript - * const endedExchange = await exchanges.end(conversationId, exchangeId); - * console.log(`Exchange ended at: ${endedExchange.endedTime}`); - * ``` - */ - end( - conversationId: string, - exchangeId: string - ): Promise; } /** @@ -238,19 +219,4 @@ export interface ConversationExchangeServiceModel { exchangeId: string, options: CreateFeedbackOptions ): Promise; - - /** - * Ends an exchange in this conversation - * - * @experimental - * @param exchangeId - The exchange to end - * @returns Promise resolving to {@link ExchangeEndResponse} - * - * @example - * ```typescript - * const endedExchange = await conversation.exchanges.end(exchangeId); - * console.log(`Exchange ended at: ${endedExchange.endedTime}`); - * ``` - */ - end(exchangeId: string): Promise; } diff --git a/src/models/conversational-agent/conversations/exchanges.types.ts b/src/models/conversational-agent/conversations/exchanges.types.ts index 98cc4f9ab..6cf7a61c0 100644 --- a/src/models/conversational-agent/conversations/exchanges.types.ts +++ b/src/models/conversational-agent/conversations/exchanges.types.ts @@ -65,8 +65,6 @@ export interface ExchangeGetByIdOptions { messageSort?: SortOrder; } -export interface ExchangeEndResponse extends ExchangeGetResponse {} - // ==================== Feedback Types ==================== export interface CreateFeedbackOptions { diff --git a/src/models/conversational-agent/conversations/types/core.types.ts b/src/models/conversational-agent/conversations/types/core.types.ts index f64818d7d..dca7667da 100644 --- a/src/models/conversational-agent/conversations/types/core.types.ts +++ b/src/models/conversational-agent/conversations/types/core.types.ts @@ -298,10 +298,6 @@ export interface Exchange { * Span identifier for distributed tracing. */ spanId?: string; - /** - * Timestamp indicating when the exchange ended, if it has ended. - */ - endedTime?: string; /** * The optional feedback rating given by the user. */ diff --git a/src/services/conversational-agent/conversations/exchanges.ts b/src/services/conversational-agent/conversations/exchanges.ts index 60cc5cc83..263f5fadb 100644 --- a/src/services/conversational-agent/conversations/exchanges.ts +++ b/src/services/conversational-agent/conversations/exchanges.ts @@ -20,8 +20,7 @@ import type { FeedbackCreateResponse, ExchangeGetAllOptions, ExchangeGetByIdOptions, - ExchangeGetResponse, - ExchangeEndResponse + ExchangeGetResponse } from '@/models/conversational-agent'; // Utils @@ -218,29 +217,4 @@ export class ExchangeService extends BaseService implements ExchangeServiceModel ); return response.data; } - - /** - * Ends an exchange, setting the endedTime timestamp and stopping any running agent job - * - * @experimental - * @param conversationId - The conversation containing the exchange - * @param exchangeId - The exchange to end - * @returns Promise resolving to {@link ExchangeEndResponse} - * - * @example - * ```typescript - * const endedExchange = await exchanges.end(conversationId, exchangeId); - * console.log(`Exchange ended at: ${endedExchange.endedTime}`); - * ``` - */ - @track('ConversationalAgent.Exchanges.End') - async end( - conversationId: string, - exchangeId: string - ): Promise { - const response = await this.post( - EXCHANGE_ENDPOINTS.END(conversationId, exchangeId) - ); - return transformExchange(response.data); - } } diff --git a/src/utils/constants/endpoints/conversational-agent.ts b/src/utils/constants/endpoints/conversational-agent.ts index ed7ed15a2..a8a6af960 100644 --- a/src/utils/constants/endpoints/conversational-agent.ts +++ b/src/utils/constants/endpoints/conversational-agent.ts @@ -26,9 +26,7 @@ export const EXCHANGE_ENDPOINTS = { GET: (conversationId: string, exchangeId: string) => `${AUTOPILOT_BASE}/api/${API_VERSION}/conversation/${conversationId}/exchange/${exchangeId}`, CREATE_FEEDBACK: (conversationId: string, exchangeId: string) => - `${AUTOPILOT_BASE}/api/${API_VERSION}/conversation/${conversationId}/exchange/${exchangeId}/feedback`, - END: (conversationId: string, exchangeId: string) => - `${AUTOPILOT_BASE}/api/${API_VERSION}/conversation/${conversationId}/exchange/${exchangeId}/end` + `${AUTOPILOT_BASE}/api/${API_VERSION}/conversation/${conversationId}/exchange/${exchangeId}/feedback` } as const; /** diff --git a/tests/unit/models/conversational-agent/conversations.test.ts b/tests/unit/models/conversational-agent/conversations.test.ts index 770595eb0..5d10717a7 100644 --- a/tests/unit/models/conversational-agent/conversations.test.ts +++ b/tests/unit/models/conversational-agent/conversations.test.ts @@ -59,8 +59,7 @@ describe('Conversation Models', () => { getAll: vi.fn(), getById: vi.fn(), createFeedback: vi.fn(), - end: vi.fn(), - }; + } as any; }); afterEach(() => { @@ -96,7 +95,6 @@ describe('Conversation Models', () => { expect(typeof conversation.exchanges.getAll).toBe('function'); expect(typeof conversation.exchanges.getById).toBe('function'); expect(typeof conversation.exchanges.createFeedback).toBe('function'); - expect(typeof conversation.exchanges.end).toBe('function'); }); }); @@ -467,42 +465,6 @@ describe('Conversation Models', () => { ).toThrow('Exchange methods are not available'); }); }); - - describe('exchanges.end()', () => { - it('should delegate to exchangeService.end', async () => { - const mockResponse = {}; - (mockExchangeService.end as any).mockResolvedValue(mockResponse); - - const data = createMockConversationData(); - const conversation = createConversationWithMethods(data, mockService, mockSessionMethods, mockExchangeService); - - const result = await conversation.exchanges.end(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); - - expect(mockExchangeService.end).toHaveBeenCalledWith( - CONVERSATIONAL_AGENT_TEST_CONSTANTS.CONVERSATION_ID, - CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID - ); - expect(result).toEqual(mockResponse); - }); - - it('should throw error if conversation id is undefined', () => { - const data = createMockConversationData({ id: undefined }); - const conversation = createConversationWithMethods(data, mockService, mockSessionMethods, mockExchangeService); - - expect( - () => conversation.exchanges.end(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID) - ).toThrow('Conversation ID is undefined'); - }); - - it('should throw error if exchange service is not available', () => { - const data = createMockConversationData(); - const conversation = createConversationWithMethods(data, mockService, mockSessionMethods); - - expect( - () => conversation.exchanges.end(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID) - ).toThrow('Exchange methods are not available'); - }); - }); }); describe('data isolation', () => { diff --git a/tests/unit/services/conversational-agent/exchanges.test.ts b/tests/unit/services/conversational-agent/exchanges.test.ts index 3594a0fb6..c9be6aa73 100644 --- a/tests/unit/services/conversational-agent/exchanges.test.ts +++ b/tests/unit/services/conversational-agent/exchanges.test.ts @@ -441,57 +441,4 @@ describe('ExchangeService Unit Tests', () => { ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); - - describe('end', () => { - it('should end an exchange and return transformed result', async () => { - const mockExchange = createMockRawExchange({ endedAt: '2024-01-01T12:00:00.000Z' }); - mockApiClient.post.mockResolvedValue(mockExchange); - - const result = await exchanges.end( - CONVERSATIONAL_AGENT_TEST_CONSTANTS.CONVERSATION_ID, - CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID - ); - - expect(result).toBeDefined(); - expect(result.id).toBe(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); - expect(result.exchangeId).toBe(CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID); - expect(result.endedTime).toBe('2024-01-01T12:00:00.000Z'); - expect((result as any).endedAt).toBeUndefined(); - - expect(mockApiClient.post).toHaveBeenCalledWith( - EXCHANGE_ENDPOINTS.END( - CONVERSATIONAL_AGENT_TEST_CONSTANTS.CONVERSATION_ID, - CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID - ), - undefined, - expect.any(Object) - ); - }); - - it('should transform messages within the ended exchange', async () => { - const mockExchange = createMockRawExchange(); - mockApiClient.post.mockResolvedValue(mockExchange); - - const result = await exchanges.end( - CONVERSATIONAL_AGENT_TEST_CONSTANTS.CONVERSATION_ID, - CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID - ); - - expect(result.messages).toHaveLength(2); - expect(result.messages[0].role).toBe('user'); - expect(result.messages[1].role).toBe('assistant'); - }); - - it('should handle API errors', async () => { - const error = createMockError(TEST_CONSTANTS.ERROR_MESSAGE); - mockApiClient.post.mockRejectedValue(error); - - await expect( - exchanges.end( - CONVERSATIONAL_AGENT_TEST_CONSTANTS.CONVERSATION_ID, - CONVERSATIONAL_AGENT_TEST_CONSTANTS.EXCHANGE_ID - ) - ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); - }); - }); }); From c3305ba0a483ac46e9ab816f9aeddec509b7ddf4 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Tue, 23 Jun 2026 14:10:59 -0400 Subject: [PATCH 12/15] test: update tests for client side end exchange --- .../exchange-event-helper.test.ts | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/tests/unit/helpers/conversational-agent/exchange-event-helper.test.ts b/tests/unit/helpers/conversational-agent/exchange-event-helper.test.ts index d348c2f65..67a16ac03 100644 --- a/tests/unit/helpers/conversational-agent/exchange-event-helper.test.ts +++ b/tests/unit/helpers/conversational-agent/exchange-event-helper.test.ts @@ -680,6 +680,154 @@ describe('ExchangeEventHelper', () => { }); }); + describe('client-side stop (sendExchangeEnd mid-stream)', () => { + it('should stop exchange while assistant message is streaming', () => { + const { emitSpy, exchange } = createExchange(); + + // User sends a message + const userMessage = exchange.startMessage({ messageId: 'user-msg', role: MessageRole.User }); + userMessage.sendContentPart({ data: 'Tell me a long story' }); + userMessage.sendMessageEnd(); + + // Assistant starts responding (server dispatches events) + exchange.dispatch({ + exchangeId: EXCHANGE_ID, + message: { + messageId: 'assistant-msg', + startMessage: { role: MessageRole.Assistant }, + }, + }); + exchange.dispatch({ + exchangeId: EXCHANGE_ID, + message: { + messageId: 'assistant-msg', + contentPart: { + contentPartId: 'cp-1', + startContentPart: { mimeType: 'text/plain' }, + }, + }, + }); + exchange.dispatch({ + exchangeId: EXCHANGE_ID, + message: { + messageId: 'assistant-msg', + contentPart: { + contentPartId: 'cp-1', + chunk: { data: 'Once upon a time...' }, + }, + }, + }); + + emitSpy.mockClear(); + + // Client triggers stop mid-stream + exchange.sendExchangeEnd(); + + expect(exchange.ended).toBe(true); + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ + exchange: expect.objectContaining({ + exchangeId: EXCHANGE_ID, + endExchange: {}, + }), + }) + ); + }); + + it('should prevent further message operations after client-side stop', () => { + const { exchange } = createExchange(); + + // Assistant is streaming + exchange.dispatch({ + exchangeId: EXCHANGE_ID, + message: { + messageId: 'assistant-msg', + startMessage: { role: MessageRole.Assistant }, + }, + }); + + // Client stops the exchange + exchange.sendExchangeEnd(); + + // No new messages can be started + expect(() => exchange.startMessage({ messageId: 'new-msg' })).toThrow( + ConversationEventInvalidOperationError + ); + }); + + it('should trigger onExchangeEnd handler when client sends stop', () => { + const { exchange } = createExchange(); + const endSpy = vi.fn(); + exchange.onExchangeEnd(endSpy); + + // Simulate assistant streaming + exchange.dispatch({ + exchangeId: EXCHANGE_ID, + message: { + messageId: 'assistant-msg', + startMessage: { role: MessageRole.Assistant }, + }, + }); + exchange.dispatch({ + exchangeId: EXCHANGE_ID, + message: { + messageId: 'assistant-msg', + contentPart: { + contentPartId: 'cp-1', + chunk: { data: 'partial response...' }, + }, + }, + }); + + // Client sends stop — the emitted event is dispatched back (echo mode) + exchange.dispatch({ + exchangeId: EXCHANGE_ID, + endExchange: {}, + }); + + expect(endSpy).toHaveBeenCalledTimes(1); + expect(exchange.ended).toBe(true); + }); + + it('should stop exchange during tool call execution', () => { + const { emitSpy, exchange } = createExchange(); + + // Assistant starts a tool call + exchange.dispatch({ + exchangeId: EXCHANGE_ID, + message: { + messageId: 'assistant-msg', + startMessage: { role: MessageRole.Assistant }, + }, + }); + exchange.dispatch({ + exchangeId: EXCHANGE_ID, + message: { + messageId: 'assistant-msg', + toolCall: { + toolCallId: 'tc-1', + startToolCall: { toolName: 'search' }, + }, + }, + }); + + emitSpy.mockClear(); + + // Client triggers stop while tool call is in progress + exchange.sendExchangeEnd(); + + expect(exchange.ended).toBe(true); + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ + exchange: expect.objectContaining({ + exchangeId: EXCHANGE_ID, + endExchange: {}, + }), + }) + ); + }); + }); + describe('replay', () => { it('should generate correct event sequence', () => { const events = Array.from(ExchangeEventHelperImpl.replay({ From 19c675dee0cdfc6fb6ba5654f88d7f46953a77b4 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Tue, 23 Jun 2026 14:18:55 -0400 Subject: [PATCH 13/15] test: update test with explicit call --- .../exchange-event-helper.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/unit/helpers/conversational-agent/exchange-event-helper.test.ts b/tests/unit/helpers/conversational-agent/exchange-event-helper.test.ts index 67a16ac03..06c5d1228 100644 --- a/tests/unit/helpers/conversational-agent/exchange-event-helper.test.ts +++ b/tests/unit/helpers/conversational-agent/exchange-event-helper.test.ts @@ -755,8 +755,8 @@ describe('ExchangeEventHelper', () => { ); }); - it('should trigger onExchangeEnd handler when client sends stop', () => { - const { exchange } = createExchange(); + it('should trigger onExchangeEnd handler on echo after client sends stop', () => { + const { emitSpy, exchange } = createExchange(); const endSpy = vi.fn(); exchange.onExchangeEnd(endSpy); @@ -779,14 +779,25 @@ describe('ExchangeEventHelper', () => { }, }); - // Client sends stop — the emitted event is dispatched back (echo mode) + // Client sends stop + exchange.sendExchangeEnd(); + + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ + exchange: expect.objectContaining({ + exchangeId: EXCHANGE_ID, + endExchange: {}, + }), + }) + ); + + // Server echoes the endExchange back — handler fires exchange.dispatch({ exchangeId: EXCHANGE_ID, endExchange: {}, }); expect(endSpy).toHaveBeenCalledTimes(1); - expect(exchange.ended).toBe(true); }); it('should stop exchange during tool call execution', () => { From 1c013d54e20c6d89268c7e16167df1633f372313 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Tue, 23 Jun 2026 14:40:34 -0400 Subject: [PATCH 14/15] docs: update comments and documentation --- .../conversational-agent.models.ts | 15 +++++++++++++-- .../conversations/types/events/exchange.types.ts | 15 +++++++++++++-- .../helpers/exchange-event-helper.ts | 4 +++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/models/conversational-agent/conversational-agent.models.ts b/src/models/conversational-agent/conversational-agent.models.ts index 07eca99c5..c0ff40e5b 100644 --- a/src/models/conversational-agent/conversational-agent.models.ts +++ b/src/models/conversational-agent/conversational-agent.models.ts @@ -38,6 +38,7 @@ import type { ConnectionStatus } from '@/core/websocket'; * S -->|onExchangeStart| E["ExchangeStream"] * S -->|onSessionEnd| SE(["session closed"]) * E -->|onMessageStart| M["MessageStream"] + * E -->|sendExchangeEnd| STOP(["stop response"]) * E -->|onExchangeEnd| EE(["exchange complete"]) * M -->|onContentPartStart| CP["ContentPartStream"] * M -->|onToolCallStart| TC["ToolCallStream"] @@ -81,10 +82,20 @@ import type { ConnectionStatus } from '@/core/websocket'; * exchange.sendMessageWithContentPart({ data: 'Hello!' }); * }); * - * // 5. End session when done + * // 5. Stop a response mid-stream + * // Use sendExchangeEnd() on any active exchange to stop the agent + * session.onSessionStarted(() => { + * const exchange = session.startExchange(); + * exchange.sendMessageWithContentPart({ data: 'Tell me a long story' }); + * + * // Stop after 5 seconds + * setTimeout(() => exchange.sendExchangeEnd(), 5000); + * }); + * + * // 6. End session when done * conversation.endSession(); * - * // 6. Retrieve conversation history (offline) + * // 7. Retrieve conversation history (offline) * const exchanges = await conversation.exchanges.getAll(); * ``` */ diff --git a/src/models/conversational-agent/conversations/types/events/exchange.types.ts b/src/models/conversational-agent/conversations/types/events/exchange.types.ts index 3b9e8522b..e9b31be24 100644 --- a/src/models/conversational-agent/conversations/types/events/exchange.types.ts +++ b/src/models/conversational-agent/conversations/types/events/exchange.types.ts @@ -228,12 +228,23 @@ export interface ExchangeStream { getMessage(messageId: string): MessageStream | undefined; /** - * Ends the exchange + * Ends the exchange. Stops further events for that exchange from being received. + * Use this to stop an in-progress agent response from the client side. * * @param endExchange - Optional end event data * - * @example Manually ending an exchange + * @example Stop a response mid-stream * ```typescript + * session.onExchangeStart((exchange) => { + * stopButton.addEventListener('click', () => exchange.sendExchangeEnd()); + * }); + * ``` + * + * @example End an exchange after sending a message + * ```typescript + * const exchange = session.startExchange(); + * exchange.sendMessageWithContentPart({ data: 'Hello!' }); + * // Later, stop the response * exchange.sendExchangeEnd(); * ``` */ diff --git a/src/services/conversational-agent/helpers/exchange-event-helper.ts b/src/services/conversational-agent/helpers/exchange-event-helper.ts index a250e9d8b..6fb8eb154 100644 --- a/src/services/conversational-agent/helpers/exchange-event-helper.ts +++ b/src/services/conversational-agent/helpers/exchange-event-helper.ts @@ -140,7 +140,9 @@ export abstract class ExchangeEventHelper extends ConversationEventHelperBase< } /** - * Ends the exchange with optional end event data. + * Ends the exchange. Stops further events for that exchange from being received. + * Can be called from either the client side (to stop an in-progress response) or + * the server/agent side (to signal natural completion). * @throws Error if exchange has already ended. */ public sendExchangeEnd(endExchange: ExchangeEndEvent = {}) { From 812afc53480cb47c1fa5ffef657013c6b1e472c0 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Tue, 23 Jun 2026 14:41:57 -0400 Subject: [PATCH 15/15] chore: update wording for example --- .../conversations/types/events/exchange.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/conversational-agent/conversations/types/events/exchange.types.ts b/src/models/conversational-agent/conversations/types/events/exchange.types.ts index e9b31be24..7b7aa5b13 100644 --- a/src/models/conversational-agent/conversations/types/events/exchange.types.ts +++ b/src/models/conversational-agent/conversations/types/events/exchange.types.ts @@ -233,7 +233,7 @@ export interface ExchangeStream { * * @param endExchange - Optional end event data * - * @example Stop a response mid-stream + * @example Manually ending an exchange and stopping a response mid-stream * ```typescript * session.onExchangeStart((exchange) => { * stopButton.addEventListener('click', () => exchange.sendExchangeEnd());