@servicetitan/titan-chatbot-api 7.1.2 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/dist/api-client/__mocks__/chatbot-api-client.mock.d.ts +1 -0
- package/dist/api-client/__mocks__/chatbot-api-client.mock.d.ts.map +1 -1
- package/dist/api-client/__mocks__/chatbot-api-client.mock.js +22 -47
- package/dist/api-client/__mocks__/chatbot-api-client.mock.js.map +1 -1
- package/dist/api-client/base/chatbot-api-client.d.ts +7 -0
- package/dist/api-client/base/chatbot-api-client.d.ts.map +1 -1
- package/dist/api-client/base/chatbot-api-client.js +3 -4
- package/dist/api-client/base/chatbot-api-client.js.map +1 -1
- package/dist/api-client/index.d.ts +2 -2
- package/dist/api-client/index.d.ts.map +1 -1
- package/dist/api-client/index.js +12 -7
- package/dist/api-client/index.js.map +1 -1
- package/dist/api-client/models/__mocks__/models.mock.js +154 -124
- package/dist/api-client/models/__mocks__/models.mock.js.map +1 -1
- package/dist/api-client/models/index.d.ts +2 -1
- package/dist/api-client/models/index.d.ts.map +1 -1
- package/dist/api-client/models/index.js +8 -7
- package/dist/api-client/models/index.js.map +1 -1
- package/dist/api-client/titan-chat/__tests__/chatbot-api-client-stream.test.d.ts +2 -0
- package/dist/api-client/titan-chat/__tests__/chatbot-api-client-stream.test.d.ts.map +1 -0
- package/dist/api-client/titan-chat/__tests__/chatbot-api-client-stream.test.js +240 -0
- package/dist/api-client/titan-chat/__tests__/chatbot-api-client-stream.test.js.map +1 -0
- package/dist/api-client/titan-chat/__tests__/native-client.test.js +6 -6
- package/dist/api-client/titan-chat/__tests__/native-client.test.js.map +1 -1
- package/dist/api-client/titan-chat/chatbot-api-client.d.ts +11 -0
- package/dist/api-client/titan-chat/chatbot-api-client.d.ts.map +1 -1
- package/dist/api-client/titan-chat/chatbot-api-client.js +69 -35
- package/dist/api-client/titan-chat/chatbot-api-client.js.map +1 -1
- package/dist/api-client/titan-chat/index.d.ts +2 -1
- package/dist/api-client/titan-chat/index.d.ts.map +1 -1
- package/dist/api-client/titan-chat/index.js +1 -0
- package/dist/api-client/titan-chat/index.js.map +1 -1
- package/dist/api-client/titan-chat/native-client.js +359 -812
- package/dist/api-client/titan-chat/native-client.js.map +1 -1
- package/dist/api-client/utils/__tests__/model-utils.test.js +454 -191
- package/dist/api-client/utils/__tests__/model-utils.test.js.map +1 -1
- package/dist/api-client/utils/model-utils.d.ts.map +1 -1
- package/dist/api-client/utils/model-utils.js +28 -25
- package/dist/api-client/utils/model-utils.js.map +1 -1
- package/dist/hooks/use-customization-chatbot.js +2 -1
- package/dist/hooks/use-customization-chatbot.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/models/__tests__/chatbot-customizations.test.d.ts +2 -0
- package/dist/models/__tests__/chatbot-customizations.test.d.ts.map +1 -0
- package/dist/models/__tests__/chatbot-customizations.test.js +36 -0
- package/dist/models/__tests__/chatbot-customizations.test.js.map +1 -0
- package/dist/models/chatbot-customizations.d.ts +17 -0
- package/dist/models/chatbot-customizations.d.ts.map +1 -1
- package/dist/models/chatbot-customizations.js +7 -1
- package/dist/models/chatbot-customizations.js.map +1 -1
- package/dist/models/index.js +1 -0
- package/dist/models/index.js.map +1 -1
- package/dist/stores/__tests__/chatbot-ui-backend.store.observability.test.d.ts +2 -0
- package/dist/stores/__tests__/chatbot-ui-backend.store.observability.test.d.ts.map +1 -0
- package/dist/stores/__tests__/chatbot-ui-backend.store.observability.test.js +107 -0
- package/dist/stores/__tests__/chatbot-ui-backend.store.observability.test.js.map +1 -0
- package/dist/stores/__tests__/chatbot-ui-backend.store.streaming.test.d.ts +2 -0
- package/dist/stores/__tests__/chatbot-ui-backend.store.streaming.test.d.ts.map +1 -0
- package/dist/stores/__tests__/chatbot-ui-backend.store.streaming.test.js +312 -0
- package/dist/stores/__tests__/chatbot-ui-backend.store.streaming.test.js.map +1 -0
- package/dist/stores/__tests__/chatbot-ui-backend.store.test.js +267 -172
- package/dist/stores/__tests__/chatbot-ui-backend.store.test.js.map +1 -1
- package/dist/stores/__tests__/chatbot-ui.store.test.js +61 -64
- package/dist/stores/__tests__/chatbot-ui.store.test.js.map +1 -1
- package/dist/stores/__tests__/filter.store.test.js +243 -116
- package/dist/stores/__tests__/filter.store.test.js.map +1 -1
- package/dist/stores/__tests__/initialize.store.test.js +9 -8
- package/dist/stores/__tests__/initialize.store.test.js.map +1 -1
- package/dist/stores/__tests__/message-feedback-guardrail.store.test.js +8 -7
- package/dist/stores/__tests__/message-feedback-guardrail.store.test.js.map +1 -1
- package/dist/stores/__tests__/message-feedback.store.test.js +34 -27
- package/dist/stores/__tests__/message-feedback.store.test.js.map +1 -1
- package/dist/stores/__tests__/session-feedback.store.test.js +9 -8
- package/dist/stores/__tests__/session-feedback.store.test.js.map +1 -1
- package/dist/stores/chatbot-ui-backend.store.d.ts +26 -2
- package/dist/stores/chatbot-ui-backend.store.d.ts.map +1 -1
- package/dist/stores/chatbot-ui-backend.store.js +295 -239
- package/dist/stores/chatbot-ui-backend.store.js.map +1 -1
- package/dist/stores/chatbot-ui.store.js +73 -46
- package/dist/stores/chatbot-ui.store.js.map +1 -1
- package/dist/stores/filter.store.js +298 -378
- package/dist/stores/filter.store.js.map +1 -1
- package/dist/stores/index.d.ts +5 -3
- package/dist/stores/index.d.ts.map +1 -1
- package/dist/stores/index.js +3 -2
- package/dist/stores/index.js.map +1 -1
- package/dist/stores/initialize.store.js +55 -51
- package/dist/stores/initialize.store.js.map +1 -1
- package/dist/stores/message-feedback-base.store.js +2 -1
- package/dist/stores/message-feedback-base.store.js.map +1 -1
- package/dist/stores/message-feedback-guardrail.store.js +50 -47
- package/dist/stores/message-feedback-guardrail.store.js.map +1 -1
- package/dist/stores/message-feedback.store.js +84 -89
- package/dist/stores/message-feedback.store.js.map +1 -1
- package/dist/stores/session-feedback.store.js +46 -39
- package/dist/stores/session-feedback.store.js.map +1 -1
- package/dist/streaming/__tests__/agent-stream.test.d.ts +2 -0
- package/dist/streaming/__tests__/agent-stream.test.d.ts.map +1 -0
- package/dist/streaming/__tests__/agent-stream.test.js +92 -0
- package/dist/streaming/__tests__/agent-stream.test.js.map +1 -0
- package/dist/streaming/agent-stream.d.ts +83 -0
- package/dist/streaming/agent-stream.d.ts.map +1 -0
- package/dist/streaming/agent-stream.js +28 -0
- package/dist/streaming/agent-stream.js.map +1 -0
- package/dist/streaming/index.d.ts +3 -0
- package/dist/streaming/index.d.ts.map +1 -0
- package/dist/streaming/index.js +4 -0
- package/dist/streaming/index.js.map +1 -0
- package/dist/streaming/run-agent-stream.d.ts +23 -0
- package/dist/streaming/run-agent-stream.d.ts.map +1 -0
- package/dist/streaming/run-agent-stream.js +83 -0
- package/dist/streaming/run-agent-stream.js.map +1 -0
- package/dist/utils/__tests__/axios-utils.test.js +8 -7
- package/dist/utils/__tests__/axios-utils.test.js.map +1 -1
- package/dist/utils/axios-utils.js +9 -7
- package/dist/utils/axios-utils.js.map +1 -1
- package/dist/utils/test-utils.js +5 -5
- package/dist/utils/test-utils.js.map +1 -1
- package/package.json +6 -3
- package/src/api-client/__mocks__/chatbot-api-client.mock.ts +1 -0
- package/src/api-client/base/chatbot-api-client.ts +11 -0
- package/src/api-client/index.ts +2 -7
- package/src/api-client/models/index.ts +15 -13
- package/src/api-client/titan-chat/__tests__/chatbot-api-client-stream.test.ts +208 -0
- package/src/api-client/titan-chat/chatbot-api-client.ts +46 -0
- package/src/api-client/titan-chat/index.ts +2 -1
- package/src/api-client/utils/model-utils.ts +4 -8
- package/src/index.ts +7 -2
- package/src/models/__tests__/chatbot-customizations.test.ts +26 -0
- package/src/models/chatbot-customizations.ts +20 -0
- package/src/stores/__tests__/chatbot-ui-backend.store.observability.test.ts +105 -0
- package/src/stores/__tests__/chatbot-ui-backend.store.streaming.test.ts +261 -0
- package/src/stores/chatbot-ui-backend.store.ts +179 -4
- package/src/stores/index.ts +5 -12
- package/src/streaming/__tests__/agent-stream.test.ts +80 -0
- package/src/streaming/agent-stream.ts +103 -0
- package/src/streaming/index.ts +2 -0
- package/src/streaming/run-agent-stream.ts +109 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/api-client/help-center/__tests__/converter-from-models.test.d.ts +0 -2
- package/dist/api-client/help-center/__tests__/converter-from-models.test.d.ts.map +0 -1
- package/dist/api-client/help-center/__tests__/converter-from-models.test.js +0 -34
- package/dist/api-client/help-center/__tests__/converter-from-models.test.js.map +0 -1
- package/dist/api-client/help-center/__tests__/converter-to-models.test.d.ts +0 -2
- package/dist/api-client/help-center/__tests__/converter-to-models.test.d.ts.map +0 -1
- package/dist/api-client/help-center/__tests__/converter-to-models.test.js +0 -82
- package/dist/api-client/help-center/__tests__/converter-to-models.test.js.map +0 -1
- package/dist/api-client/help-center/chatbot-api-client.d.ts +0 -32
- package/dist/api-client/help-center/chatbot-api-client.d.ts.map +0 -1
- package/dist/api-client/help-center/chatbot-api-client.js +0 -102
- package/dist/api-client/help-center/chatbot-api-client.js.map +0 -1
- package/dist/api-client/help-center/converter-from-models.d.ts +0 -13
- package/dist/api-client/help-center/converter-from-models.d.ts.map +0 -1
- package/dist/api-client/help-center/converter-from-models.js +0 -114
- package/dist/api-client/help-center/converter-from-models.js.map +0 -1
- package/dist/api-client/help-center/converter-to-models.d.ts +0 -13
- package/dist/api-client/help-center/converter-to-models.d.ts.map +0 -1
- package/dist/api-client/help-center/converter-to-models.js +0 -98
- package/dist/api-client/help-center/converter-to-models.js.map +0 -1
- package/dist/api-client/help-center/index.d.ts +0 -2
- package/dist/api-client/help-center/index.d.ts.map +0 -1
- package/dist/api-client/help-center/index.js +0 -2
- package/dist/api-client/help-center/index.js.map +0 -1
- package/dist/api-client/help-center/native-client.d.ts +0 -1268
- package/dist/api-client/help-center/native-client.d.ts.map +0 -1
- package/dist/api-client/help-center/native-client.js +0 -6242
- package/dist/api-client/help-center/native-client.js.map +0 -1
- package/src/api-client/help-center/__tests__/converter-from-models.test.ts +0 -41
- package/src/api-client/help-center/__tests__/converter-to-models.test.ts +0 -89
- package/src/api-client/help-center/chatbot-api-client.ts +0 -122
- package/src/api-client/help-center/converter-from-models.ts +0 -133
- package/src/api-client/help-center/converter-to-models.ts +0 -127
- package/src/api-client/help-center/index.ts +0 -1
- package/src/api-client/help-center/native-client.ts +0 -5727
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { inject, injectable } from '@servicetitan/react-ioc';
|
|
2
2
|
import { Models } from '..';
|
|
3
|
+
import { AgentStreamHandlers, runAgentStream } from '../../streaming';
|
|
3
4
|
import {
|
|
4
5
|
CHATBOT_CLIENT_SETTINGS,
|
|
5
6
|
FirstParameterType,
|
|
@@ -10,6 +11,9 @@ import {
|
|
|
10
11
|
// eslint-disable-next-line no-restricted-imports
|
|
11
12
|
import { Client } from './native-client';
|
|
12
13
|
|
|
14
|
+
/** Streaming endpoint template, mirroring the generated client's `/api/v{version}/message`. */
|
|
15
|
+
const AGENT_STREAM_PATH_TEMPLATE = '/api/v{version}/message/stream';
|
|
16
|
+
|
|
13
17
|
/**
|
|
14
18
|
* Settings for the chatbot client, which includes a factory for constructor parameters, and additional settings
|
|
15
19
|
* like version and clientId which are used in the API calls.
|
|
@@ -21,6 +25,11 @@ export interface IChatbotClientSettingsTitanChat extends IChatbotClientSettings
|
|
|
21
25
|
};
|
|
22
26
|
version: string;
|
|
23
27
|
clientId: string;
|
|
28
|
+
/**
|
|
29
|
+
* Extra headers for the SSE streaming request. `X-Client-ID` is always sent (from `clientId`);
|
|
30
|
+
* use this for anything the fetch http client would otherwise add (e.g. Authorization). Optional.
|
|
31
|
+
*/
|
|
32
|
+
streamHeadersFactory?: () => Record<string, string>;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
/**
|
|
@@ -30,6 +39,10 @@ export interface IChatbotClientSettingsTitanChat extends IChatbotClientSettings
|
|
|
30
39
|
@injectable()
|
|
31
40
|
export class ChatbotApiClient implements IChatbotApiClient {
|
|
32
41
|
private readonly client: Client;
|
|
42
|
+
/** Same base URL the generated `Client` uses, so streaming URLs are built identically. */
|
|
43
|
+
private readonly baseUrl: string;
|
|
44
|
+
/** Same `fetch` the generated `Client` uses, so the SSE request gets identical auth/headers. */
|
|
45
|
+
private readonly httpFetch?: typeof fetch;
|
|
33
46
|
|
|
34
47
|
get version(): string {
|
|
35
48
|
return this.chatbotApiClientSettings.version;
|
|
@@ -46,6 +59,13 @@ export class ChatbotApiClient implements IChatbotApiClient {
|
|
|
46
59
|
const { baseUrl, httpClient } =
|
|
47
60
|
this.chatbotApiClientSettings.constructorParametersFactory();
|
|
48
61
|
this.client = new Client(baseUrl, httpClient);
|
|
62
|
+
// Mirror the generated client's `this.baseUrl = baseUrl ?? ""`.
|
|
63
|
+
this.baseUrl = baseUrl ?? '';
|
|
64
|
+
/*
|
|
65
|
+
* Reuse the same fetch the generated client uses, so the SSE request goes through the
|
|
66
|
+
* consumer's request configuration (auth token, X-HC-Instance, credentials, …).
|
|
67
|
+
*/
|
|
68
|
+
this.httpFetch = httpClient?.fetch as typeof fetch | undefined;
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
postFeedback(body: Models.Feedback, abortSignal?: AbortSignal) {
|
|
@@ -56,6 +76,32 @@ export class ChatbotApiClient implements IChatbotApiClient {
|
|
|
56
76
|
return this.client.message(this.version, this.clientId, body, abortSignal);
|
|
57
77
|
}
|
|
58
78
|
|
|
79
|
+
streamMessage(
|
|
80
|
+
body: Models.IUserMessage,
|
|
81
|
+
handlers: AgentStreamHandlers,
|
|
82
|
+
abortSignal?: AbortSignal
|
|
83
|
+
): Promise<Models.BotMessage> {
|
|
84
|
+
/*
|
|
85
|
+
* Build the URL exactly as the generated Client does for `/api/v{version}/message`:
|
|
86
|
+
* substitute {version}, then strip a dangling query separator.
|
|
87
|
+
*/
|
|
88
|
+
const url = (this.baseUrl + AGENT_STREAM_PATH_TEMPLATE)
|
|
89
|
+
.replace('{version}', encodeURIComponent('' + this.version))
|
|
90
|
+
.replace(/[?&]$/, '');
|
|
91
|
+
|
|
92
|
+
return runAgentStream({
|
|
93
|
+
url,
|
|
94
|
+
fetch: this.httpFetch,
|
|
95
|
+
headers: {
|
|
96
|
+
'X-Client-ID': this.clientId,
|
|
97
|
+
...(this.chatbotApiClientSettings.streamHeadersFactory?.() ?? {}),
|
|
98
|
+
},
|
|
99
|
+
body,
|
|
100
|
+
handlers,
|
|
101
|
+
abortSignal,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
59
105
|
postFollowUpEmail(body: Models.UserMessage, abortSignal?: AbortSignal) {
|
|
60
106
|
return this.client.followUpEmail(this.version, this.clientId, body, abortSignal);
|
|
61
107
|
}
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export type { IChatbotClientSettingsTitanChat } from './chatbot-api-client';
|
|
2
|
+
export { ChatbotApiClient } from './chatbot-api-client';
|
|
@@ -11,11 +11,9 @@ export function createSelectionsModel(
|
|
|
11
11
|
const process = (filters: Models.IOption[]): Models.Selections | undefined => {
|
|
12
12
|
let result: Models.Selections | undefined;
|
|
13
13
|
const ensureResult = () => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
});
|
|
18
|
-
}
|
|
14
|
+
result ??= new Models.Selections({
|
|
15
|
+
subOptions: {},
|
|
16
|
+
});
|
|
19
17
|
return result.subOptions!;
|
|
20
18
|
};
|
|
21
19
|
|
|
@@ -74,9 +72,7 @@ export function createSelectionsModel(
|
|
|
74
72
|
subFilterSelection = new Models.Selections({});
|
|
75
73
|
subOptions[filter.key] = subFilterSelection;
|
|
76
74
|
}
|
|
77
|
-
|
|
78
|
-
subFilterSelection.subOptions = {};
|
|
79
|
-
}
|
|
75
|
+
subFilterSelection.subOptions ??= {};
|
|
80
76
|
subFilterSelection.subOptions![key] = value;
|
|
81
77
|
}
|
|
82
78
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/* eslint-disable no-restricted-imports */
|
|
2
2
|
export * from './api-client';
|
|
3
3
|
export * from './stores';
|
|
4
|
-
export * from './
|
|
4
|
+
export * from './streaming';
|
|
5
|
+
export type * from './models';
|
|
6
|
+
// Runtime helpers from models (the `export type *` above is type-only).
|
|
7
|
+
export {
|
|
8
|
+
isChatbotStreamingEnabled,
|
|
9
|
+
DEFAULT_STREAMING_INACTIVITY_TIMEOUT_MS,
|
|
10
|
+
} from './models/chatbot-customizations';
|
|
5
11
|
export * from './hooks/use-customization-chatbot';
|
|
6
|
-
export * as NativeClientHC from './api-client/help-center/native-client';
|
|
7
12
|
export * as NativeClientTC from './api-client/titan-chat/native-client';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from '@jest/globals';
|
|
2
|
+
import { ChatbotCustomizations, isChatbotStreamingEnabled } from '../chatbot-customizations';
|
|
3
|
+
|
|
4
|
+
describe('ChatbotCustomizations streaming setting', () => {
|
|
5
|
+
test('streaming is disabled when customizations are undefined', () => {
|
|
6
|
+
expect(isChatbotStreamingEnabled(undefined)).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('streaming is disabled when the streaming setting is absent', () => {
|
|
10
|
+
const c: ChatbotCustomizations = { feedback: { title: 'x' } };
|
|
11
|
+
expect(isChatbotStreamingEnabled(c)).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('streaming is disabled when explicitly false', () => {
|
|
15
|
+
const c: ChatbotCustomizations = { streaming: { enabled: false } };
|
|
16
|
+
expect(isChatbotStreamingEnabled(c)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('streaming is enabled only when explicitly true', () => {
|
|
20
|
+
const c: ChatbotCustomizations = {
|
|
21
|
+
streaming: { enabled: true, inactivityTimeoutMs: 16_000 },
|
|
22
|
+
};
|
|
23
|
+
expect(isChatbotStreamingEnabled(c)).toBe(true);
|
|
24
|
+
expect(c.streaming?.inactivityTimeoutMs).toBe(16_000);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -10,6 +10,19 @@ export type FilterDefaultSelectionFn = (filterKey: string, optionKey: string) =>
|
|
|
10
10
|
|
|
11
11
|
export type ChatbotCustomizations = ChatCustomizations &
|
|
12
12
|
Partial<{
|
|
13
|
+
/**
|
|
14
|
+
* Agent-progress SSE streaming. Disabled by default; when disabled (or when the streaming
|
|
15
|
+
* endpoint is unreachable at connect time) the widget falls back to the non-streaming path.
|
|
16
|
+
*/
|
|
17
|
+
streaming?: {
|
|
18
|
+
/** Opt in to the SSE streaming send-path. Defaults to false. */
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Inactivity threshold (ms) before the "Still working on it…" keepalive is shown.
|
|
22
|
+
* Defaults to 16000 (matching the legacy hard timeout).
|
|
23
|
+
*/
|
|
24
|
+
inactivityTimeoutMs?: number;
|
|
25
|
+
};
|
|
13
26
|
timeouts?: {
|
|
14
27
|
chatbotRequestTimeoutMs?: number;
|
|
15
28
|
};
|
|
@@ -31,3 +44,10 @@ export type ChatbotCustomizations = ChatCustomizations &
|
|
|
31
44
|
isDefaultSelected?: FilterDefaultSelectionFn;
|
|
32
45
|
};
|
|
33
46
|
}>;
|
|
47
|
+
|
|
48
|
+
/** Default inactivity threshold before the keepalive message is shown (ms). */
|
|
49
|
+
export const DEFAULT_STREAMING_INACTIVITY_TIMEOUT_MS = 16_000;
|
|
50
|
+
|
|
51
|
+
/** Whether the SSE streaming send-path is enabled. Defaults to off. */
|
|
52
|
+
export const isChatbotStreamingEnabled = (customizations?: ChatbotCustomizations): boolean =>
|
|
53
|
+
customizations?.streaming?.enabled ?? false;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { expect } from '@jest/globals';
|
|
2
|
+
import { ILog, Log, LogError, LogInfo, LogWarning } from '@servicetitan/log-service';
|
|
3
|
+
import { Container } from '@servicetitan/react-ioc';
|
|
4
|
+
import { ChatMessageModelText, ChatUiEventListener } from '@servicetitan/titan-chat-ui-common';
|
|
5
|
+
import { CHATBOT_API_CLIENT, IChatbotApiClient, Models, ModelsMocks } from '../../api-client';
|
|
6
|
+
import { ChatbotApiClientMock } from '../../api-client/__mocks__/chatbot-api-client.mock';
|
|
7
|
+
import { AgentStreamHandlers } from '../../streaming';
|
|
8
|
+
import { initTestContainer } from '../../utils/test-utils';
|
|
9
|
+
import { ChatbotUiBackendStore } from '../chatbot-ui-backend.store';
|
|
10
|
+
import { CHATBOT_UI_STORE_TOKEN, ChatbotUiStore } from '../chatbot-ui.store';
|
|
11
|
+
|
|
12
|
+
const initContainer = initTestContainer(ChatbotUiBackendStore, container => {
|
|
13
|
+
container
|
|
14
|
+
.bind<ILog>(Log)
|
|
15
|
+
.to(
|
|
16
|
+
class implements ILog {
|
|
17
|
+
error: (entry: LogError) => void = jest.fn();
|
|
18
|
+
info: (entry: LogInfo) => void = jest.fn();
|
|
19
|
+
warning: (entry: LogWarning) => void = jest.fn();
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
.inSingletonScope();
|
|
23
|
+
container
|
|
24
|
+
.bind<IChatbotApiClient>(CHATBOT_API_CLIENT)
|
|
25
|
+
.toConstantValue(new ChatbotApiClientMock());
|
|
26
|
+
container.bind(CHATBOT_UI_STORE_TOKEN).to(ChatbotUiStore).inSingletonScope();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('[ChatbotUiBackendStore] streaming observability', () => {
|
|
30
|
+
let container: Container;
|
|
31
|
+
let store: ChatbotUiBackendStore;
|
|
32
|
+
let chatbotApi: ChatbotApiClientMock;
|
|
33
|
+
let chatUiStore: ChatbotUiStore;
|
|
34
|
+
let log: ILog;
|
|
35
|
+
|
|
36
|
+
const runListener = async (listener: ChatUiEventListener, args: unknown[]) =>
|
|
37
|
+
new Promise<void>((resolve, reject) => listener.apply(store, [resolve, reject, ...args]));
|
|
38
|
+
|
|
39
|
+
const sendStreamed = async () => {
|
|
40
|
+
chatUiStore.setCustomizationContext({ streaming: { enabled: true } });
|
|
41
|
+
await chatUiStore.sendMessageText('q');
|
|
42
|
+
const message = chatUiStore.messages.at(-1)! as ChatMessageModelText;
|
|
43
|
+
await runListener(store.handleMessageSend, [message]);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const codesLogged = () =>
|
|
47
|
+
(log.info as jest.Mock).mock.calls.map(c => (c[0] as { code: string }).code);
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
container = initContainer();
|
|
51
|
+
store = container.get(ChatbotUiBackendStore);
|
|
52
|
+
chatbotApi = container.get<IChatbotApiClient>(CHATBOT_API_CLIENT) as ChatbotApiClientMock;
|
|
53
|
+
chatUiStore = container.get<ChatbotUiStore>(CHATBOT_UI_STORE_TOKEN);
|
|
54
|
+
log = container.get<ILog>(Log);
|
|
55
|
+
chatbotApi.getOptions.mockResolvedValue(ModelsMocks.mockFrontendModel());
|
|
56
|
+
chatbotApi.postSession.mockResolvedValue(ModelsMocks.mockSession());
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('logs the connection lifecycle (connected → completed)', async () => {
|
|
60
|
+
chatbotApi.streamMessage.mockImplementation(
|
|
61
|
+
(_m: Models.IUserMessage, h: AgentStreamHandlers) => {
|
|
62
|
+
h.onConnected?.();
|
|
63
|
+
h.onCompleted?.();
|
|
64
|
+
return ModelsMocks.mockBotMessage({ answer: 'done' });
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
await sendStreamed();
|
|
69
|
+
|
|
70
|
+
const codes = codesLogged();
|
|
71
|
+
expect(codes).toContain('TitanChatbot_Streaming_connected');
|
|
72
|
+
expect(codes).toContain('TitanChatbot_Streaming_completed');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('logs disconnected and timed_out lifecycle phases', async () => {
|
|
76
|
+
chatbotApi.streamMessage.mockImplementation(
|
|
77
|
+
(_m: Models.IUserMessage, h: AgentStreamHandlers) => {
|
|
78
|
+
h.onConnected?.();
|
|
79
|
+
h.onDisconnected?.();
|
|
80
|
+
h.onTimeout?.();
|
|
81
|
+
return ModelsMocks.mockBotMessage({ answer: 'done' });
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await sendStreamed();
|
|
86
|
+
|
|
87
|
+
const codes = codesLogged();
|
|
88
|
+
expect(codes).toContain('TitanChatbot_Streaming_disconnected');
|
|
89
|
+
expect(codes).toContain('TitanChatbot_Streaming_timed_out');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('tracks fallback separately and does not log a completed streaming run', async () => {
|
|
93
|
+
chatbotApi.streamMessage.mockRejectedValue(new Error('unreachable'));
|
|
94
|
+
chatbotApi.postMessage.mockResolvedValue(
|
|
95
|
+
ModelsMocks.mockBotMessage({ answer: 'fallback' })
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await sendStreamed();
|
|
99
|
+
|
|
100
|
+
expect(store.streamingFallbackCount).toBe(1);
|
|
101
|
+
const codes = codesLogged();
|
|
102
|
+
expect(codes).toContain('TitanChatbot_Streaming_fallback');
|
|
103
|
+
expect(codes).not.toContain('TitanChatbot_Streaming_completed');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { expect } from '@jest/globals';
|
|
2
|
+
import { ILog, Log, LogError, LogInfo, LogWarning } from '@servicetitan/log-service';
|
|
3
|
+
import { Container } from '@servicetitan/react-ioc';
|
|
4
|
+
import { ChatMessageModelText, ChatUiEventListener } from '@servicetitan/titan-chat-ui-common';
|
|
5
|
+
import { CHATBOT_API_CLIENT, IChatbotApiClient, Models, ModelsMocks } from '../../api-client';
|
|
6
|
+
import { ChatbotApiClientMock } from '../../api-client/__mocks__/chatbot-api-client.mock';
|
|
7
|
+
import { AgentStreamError, AgentStreamHandlers } from '../../streaming';
|
|
8
|
+
import { initTestContainer } from '../../utils/test-utils';
|
|
9
|
+
import { ChatbotUiBackendStore } from '../chatbot-ui-backend.store';
|
|
10
|
+
import { CHATBOT_UI_STORE_TOKEN, ChatbotUiStore } from '../chatbot-ui.store';
|
|
11
|
+
|
|
12
|
+
const initContainer = initTestContainer(ChatbotUiBackendStore, container => {
|
|
13
|
+
container
|
|
14
|
+
.bind<ILog>(Log)
|
|
15
|
+
.to(
|
|
16
|
+
class implements ILog {
|
|
17
|
+
error: (entry: LogError) => void = jest.fn();
|
|
18
|
+
info: (entry: LogInfo) => void = jest.fn();
|
|
19
|
+
warning: (entry: LogWarning) => void = jest.fn();
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
.inSingletonScope();
|
|
23
|
+
container
|
|
24
|
+
.bind<IChatbotApiClient>(CHATBOT_API_CLIENT)
|
|
25
|
+
.toConstantValue(new ChatbotApiClientMock());
|
|
26
|
+
container.bind(CHATBOT_UI_STORE_TOKEN).to(ChatbotUiStore).inSingletonScope();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('[ChatbotUiBackendStore] streaming', () => {
|
|
30
|
+
let container: Container;
|
|
31
|
+
let store: ChatbotUiBackendStore;
|
|
32
|
+
let chatbotApi: ChatbotApiClientMock;
|
|
33
|
+
let chatUiStore: ChatbotUiStore;
|
|
34
|
+
|
|
35
|
+
const runListener = async <T = void>(listener: ChatUiEventListener<T>, args: unknown[]) =>
|
|
36
|
+
new Promise<T>((resolve, reject) => listener.apply(store, [resolve, reject, ...args]));
|
|
37
|
+
|
|
38
|
+
const mockSession = () => {
|
|
39
|
+
chatbotApi.getOptions.mockResolvedValue(ModelsMocks.mockFrontendModel());
|
|
40
|
+
chatbotApi.postSession.mockResolvedValue(ModelsMocks.mockSession());
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const enableStreaming = () =>
|
|
44
|
+
chatUiStore.setCustomizationContext({ streaming: { enabled: true } });
|
|
45
|
+
|
|
46
|
+
const sendQuestion = async (text = 'user question') => {
|
|
47
|
+
await chatUiStore.sendMessageText(text);
|
|
48
|
+
return chatUiStore.messages.at(-1)! as ChatMessageModelText;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
container = initContainer();
|
|
53
|
+
store = container.get(ChatbotUiBackendStore);
|
|
54
|
+
chatbotApi = container.get<IChatbotApiClient>(CHATBOT_API_CLIENT) as ChatbotApiClientMock;
|
|
55
|
+
chatUiStore = container.get<ChatbotUiStore>(CHATBOT_UI_STORE_TOKEN);
|
|
56
|
+
mockSession();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('uses the streaming path when enabled, not postMessage', async () => {
|
|
60
|
+
enableStreaming();
|
|
61
|
+
chatbotApi.streamMessage.mockResolvedValue(
|
|
62
|
+
ModelsMocks.mockBotMessage({ answer: 'streamed' })
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const message = await sendQuestion();
|
|
66
|
+
await runListener(store.handleMessageSend, [message]);
|
|
67
|
+
|
|
68
|
+
expect(chatbotApi.streamMessage).toHaveBeenCalledTimes(1);
|
|
69
|
+
expect(chatbotApi.postMessage).not.toHaveBeenCalled();
|
|
70
|
+
expect((chatUiStore.messages.at(-1) as ChatMessageModelText).message).toBe('streamed');
|
|
71
|
+
expect(chatUiStore.isAgentTyping).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('uses the non-streaming path when disabled', async () => {
|
|
75
|
+
chatbotApi.postMessage.mockResolvedValue(ModelsMocks.mockBotMessage({ answer: 'regular' }));
|
|
76
|
+
|
|
77
|
+
const message = await sendQuestion();
|
|
78
|
+
await runListener(store.handleMessageSend, [message]);
|
|
79
|
+
|
|
80
|
+
expect(chatbotApi.postMessage).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(chatbotApi.streamMessage).not.toHaveBeenCalled();
|
|
82
|
+
expect((chatUiStore.messages.at(-1) as ChatMessageModelText).message).toBe('regular');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('maps progress events onto the observable progress model', async () => {
|
|
86
|
+
enableStreaming();
|
|
87
|
+
chatbotApi.streamMessage.mockImplementation(
|
|
88
|
+
(_m: Models.IUserMessage, h: AgentStreamHandlers) => {
|
|
89
|
+
h.onStatus?.('Thinking…');
|
|
90
|
+
h.onText?.('✓ searched');
|
|
91
|
+
h.onPlan?.([{ id: '1', title: 'Plan' }]);
|
|
92
|
+
return ModelsMocks.mockBotMessage({ answer: 'done' });
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const message = await sendQuestion();
|
|
97
|
+
await runListener(store.handleMessageSend, [message]);
|
|
98
|
+
|
|
99
|
+
expect(store.streamingProgress.logLines).toContain('✓ searched');
|
|
100
|
+
expect(store.streamingProgress.steps.map(s => s.id)).toEqual(['1']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('scrolls the chat after each streaming progress update', async () => {
|
|
104
|
+
enableStreaming();
|
|
105
|
+
const triggerScroll = jest.spyOn(chatUiStore, 'triggerScroll');
|
|
106
|
+
chatbotApi.streamMessage.mockImplementation(
|
|
107
|
+
(_m: Models.IUserMessage, h: AgentStreamHandlers) => {
|
|
108
|
+
/*
|
|
109
|
+
* Each progress event must scroll exactly once (isolated from the scrolls that
|
|
110
|
+
* delivering the final answer also performs).
|
|
111
|
+
*/
|
|
112
|
+
const scrollsFor = (fn: () => void) => {
|
|
113
|
+
triggerScroll.mockClear();
|
|
114
|
+
fn();
|
|
115
|
+
return triggerScroll.mock.calls.length;
|
|
116
|
+
};
|
|
117
|
+
expect(scrollsFor(() => h.onStatus?.('Thinking…'))).toBe(1);
|
|
118
|
+
expect(scrollsFor(() => h.onText?.('✓ searched'))).toBe(1);
|
|
119
|
+
expect(scrollsFor(() => h.onPlan?.([{ id: '1', title: 'Plan' }]))).toBe(1);
|
|
120
|
+
expect(scrollsFor(() => h.onStepActive?.('1'))).toBe(1);
|
|
121
|
+
expect(scrollsFor(() => h.onInactivity?.())).toBe(1);
|
|
122
|
+
return ModelsMocks.mockBotMessage({ answer: 'done' });
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const message = await sendQuestion();
|
|
127
|
+
await runListener(store.handleMessageSend, [message]);
|
|
128
|
+
expect(triggerScroll).toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('onStepActive marks the active plan step (earlier done, later pending)', async () => {
|
|
132
|
+
enableStreaming();
|
|
133
|
+
chatbotApi.streamMessage.mockImplementation(
|
|
134
|
+
(_m: Models.IUserMessage, h: AgentStreamHandlers) => {
|
|
135
|
+
h.onPlan?.([
|
|
136
|
+
{ id: '1', title: 'Look up' },
|
|
137
|
+
{ id: '2', title: 'Search' },
|
|
138
|
+
{ id: '3', title: 'Answer' },
|
|
139
|
+
]);
|
|
140
|
+
h.onStepActive?.('2');
|
|
141
|
+
// Capture mid-run state before run.finished completes all steps.
|
|
142
|
+
expect(store.streamingProgress.steps.map(s => s.status)).toEqual([
|
|
143
|
+
'done',
|
|
144
|
+
'active',
|
|
145
|
+
'pending',
|
|
146
|
+
]);
|
|
147
|
+
return ModelsMocks.mockBotMessage({ answer: 'done' });
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const message = await sendQuestion();
|
|
152
|
+
await runListener(store.handleMessageSend, [message]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('marks all plan steps done once the run finishes', async () => {
|
|
156
|
+
enableStreaming();
|
|
157
|
+
chatbotApi.streamMessage.mockImplementation(
|
|
158
|
+
(_m: Models.IUserMessage, h: AgentStreamHandlers) => {
|
|
159
|
+
h.onPlan?.([
|
|
160
|
+
{ id: '1', title: 'Look up' },
|
|
161
|
+
{ id: '2', title: 'Answer' },
|
|
162
|
+
]);
|
|
163
|
+
h.onStepActive?.('1');
|
|
164
|
+
return ModelsMocks.mockBotMessage({ answer: 'done' });
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const message = await sendQuestion();
|
|
169
|
+
await runListener(store.handleMessageSend, [message]);
|
|
170
|
+
|
|
171
|
+
expect(store.streamingProgress.steps.map(s => s.status)).toEqual(['done', 'done']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('shows the "Still working on it…" keepalive on inactivity', async () => {
|
|
175
|
+
enableStreaming();
|
|
176
|
+
chatbotApi.streamMessage.mockImplementation(
|
|
177
|
+
(_m: Models.IUserMessage, h: AgentStreamHandlers) => {
|
|
178
|
+
h.onInactivity?.();
|
|
179
|
+
expect(store.streamingProgress.keepaliveText).toBe('Still working on it…');
|
|
180
|
+
return ModelsMocks.mockBotMessage({ answer: 'done' });
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const message = await sendQuestion();
|
|
185
|
+
await runListener(store.handleMessageSend, [message]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('passes the configured inactivity timeout (defaulting to 16000)', async () => {
|
|
189
|
+
enableStreaming();
|
|
190
|
+
chatbotApi.streamMessage.mockResolvedValue(ModelsMocks.mockBotMessage({ answer: 'done' }));
|
|
191
|
+
|
|
192
|
+
const message = await sendQuestion();
|
|
193
|
+
await runListener(store.handleMessageSend, [message]);
|
|
194
|
+
|
|
195
|
+
const handlers = chatbotApi.streamMessage.mock.calls[0][1] as AgentStreamHandlers;
|
|
196
|
+
expect(handlers.inactivityTimeoutMs).toBe(16_000);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('falls back to non-streaming silently when the stream is unreachable at connect time', async () => {
|
|
200
|
+
enableStreaming();
|
|
201
|
+
// Rejects WITHOUT ever calling onConnected → connect-time failure.
|
|
202
|
+
chatbotApi.streamMessage.mockRejectedValue(new Error('connect failed'));
|
|
203
|
+
chatbotApi.postMessage.mockResolvedValue(
|
|
204
|
+
ModelsMocks.mockBotMessage({ answer: 'fallback answer' })
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const message = await sendQuestion();
|
|
208
|
+
await runListener(store.handleMessageSend, [message]);
|
|
209
|
+
|
|
210
|
+
expect(chatbotApi.postMessage).toHaveBeenCalledTimes(1);
|
|
211
|
+
expect((chatUiStore.messages.at(-1) as ChatMessageModelText).message).toBe(
|
|
212
|
+
'fallback answer'
|
|
213
|
+
);
|
|
214
|
+
expect(chatUiStore.isError).toBe(false);
|
|
215
|
+
expect(store.streamingFallbackCount).toBe(1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('shows the drop error when the connection fails after connecting', async () => {
|
|
219
|
+
enableStreaming();
|
|
220
|
+
chatbotApi.streamMessage.mockImplementation(
|
|
221
|
+
(_m: Models.IUserMessage, h: AgentStreamHandlers) => {
|
|
222
|
+
h.onConnected?.();
|
|
223
|
+
throw new Error('socket dropped');
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const message = await sendQuestion();
|
|
228
|
+
await runListener(store.handleMessageSend, [message]);
|
|
229
|
+
|
|
230
|
+
expect(chatbotApi.postMessage).not.toHaveBeenCalled();
|
|
231
|
+
expect(chatUiStore.isError).toBe(true);
|
|
232
|
+
expect(chatUiStore.error?.message).toContain('Something went wrong during this step');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('surfaces the agent step error message from run.error', async () => {
|
|
236
|
+
enableStreaming();
|
|
237
|
+
chatbotApi.streamMessage.mockImplementation(
|
|
238
|
+
(_m: Models.IUserMessage, h: AgentStreamHandlers) => {
|
|
239
|
+
h.onConnected?.();
|
|
240
|
+
throw new AgentStreamError('Search step failed');
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const message = await sendQuestion();
|
|
245
|
+
await runListener(store.handleMessageSend, [message]);
|
|
246
|
+
|
|
247
|
+
expect(chatUiStore.isError).toBe(true);
|
|
248
|
+
expect(chatUiStore.error?.message).toContain('Search step failed');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('sends the active session id with the streamed message (session isolation)', async () => {
|
|
252
|
+
enableStreaming();
|
|
253
|
+
chatbotApi.streamMessage.mockResolvedValue(ModelsMocks.mockBotMessage({ answer: 'done' }));
|
|
254
|
+
|
|
255
|
+
const message = await sendQuestion();
|
|
256
|
+
await runListener(store.handleMessageSend, [message]);
|
|
257
|
+
|
|
258
|
+
const sentBody = chatbotApi.streamMessage.mock.calls[0][0] as Models.IUserMessage;
|
|
259
|
+
expect(sentBody.sessionId).toBe(store.session?.id);
|
|
260
|
+
});
|
|
261
|
+
});
|