@servicetitan/titan-chatbot-api 8.0.0 → 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 +12 -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 +1 -0
- 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.map +1 -1
- package/dist/api-client/index.d.ts +0 -1
- package/dist/api-client/index.d.ts.map +1 -1
- package/dist/api-client/index.js +0 -2
- package/dist/api-client/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/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 +29 -0
- package/dist/api-client/titan-chat/chatbot-api-client.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- 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 +6 -1
- package/dist/models/chatbot-customizations.js.map +1 -1
- package/dist/models/index.d.ts +1 -1
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/index.js +1 -1
- 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/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 +129 -4
- package/dist/stores/chatbot-ui-backend.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/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 +0 -1
- 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/index.ts +6 -1
- package/src/models/__tests__/chatbot-customizations.test.ts +26 -0
- package/src/models/chatbot-customizations.ts +20 -0
- package/src/models/index.ts +1 -1
- 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/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 -67
- 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 -83
- 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 -101
- 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 -117
- 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 -101
- package/dist/api-client/help-center/converter-to-models.js.map +0 -1
- package/dist/api-client/help-center/index.d.ts +0 -3
- package/dist/api-client/help-center/index.d.ts.map +0 -1
- package/dist/api-client/help-center/index.js +0 -3
- 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 -4550
- 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 -2
- package/src/api-client/help-center/native-client.ts +0 -5727
|
@@ -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
|
+
});
|
|
@@ -9,16 +9,25 @@ import {
|
|
|
9
9
|
ChatUiEvent,
|
|
10
10
|
ChatUiEventListener,
|
|
11
11
|
IChatUiBackendStore,
|
|
12
|
+
StreamingProgressModel,
|
|
12
13
|
} from '@servicetitan/titan-chat-ui-common';
|
|
13
14
|
import axios from 'axios';
|
|
14
15
|
import { makeObservable, runInAction } from 'mobx';
|
|
15
16
|
import { CHATBOT_API_CLIENT, IChatbotApiClient, Models, ModelsUtils } from '../api-client';
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_STREAMING_INACTIVITY_TIMEOUT_MS,
|
|
19
|
+
isChatbotStreamingEnabled,
|
|
20
|
+
} from '../models/chatbot-customizations';
|
|
21
|
+
import { AgentStreamError, AgentStreamHandlers } from '../streaming';
|
|
16
22
|
import { withTimeout } from '../utils/axios-utils';
|
|
17
23
|
import { CHATBOT_UI_STORE_TOKEN, ChatbotUiEvent, IChatbotUiStore } from './chatbot-ui.store';
|
|
18
24
|
import { InitializeStore } from './initialize.store';
|
|
19
25
|
|
|
20
26
|
const DEFAULT_CHATBOT_ASK_BOT_TIMEOUT_MS = 31000; // 31 seconds
|
|
21
27
|
|
|
28
|
+
const STREAMING_KEEPALIVE_TEXT = 'Still working on it…';
|
|
29
|
+
const STREAMING_STEP_ERROR = 'Something went wrong during this step. Please try again.';
|
|
30
|
+
|
|
22
31
|
const isAbortedError = (error: unknown): boolean =>
|
|
23
32
|
axios.isCancel(error) || (error as { name?: string } | null)?.name === 'AbortError';
|
|
24
33
|
|
|
@@ -28,6 +37,10 @@ export const CHATBOT_UI_BACKEND_STORE_TOKEN = symbolToken<IChatbotUiBackendStore
|
|
|
28
37
|
|
|
29
38
|
export interface IChatbotUiBackendStore extends IChatUiBackendStore {
|
|
30
39
|
session?: Models.ISession;
|
|
40
|
+
/** Live progress for the in-flight streamed run. */
|
|
41
|
+
streamingProgress: StreamingProgressModel;
|
|
42
|
+
/** Count of streaming → non-streaming fallbacks. */
|
|
43
|
+
streamingFallbackCount: number;
|
|
31
44
|
initialize: () => void;
|
|
32
45
|
saveCurrentState: () => void;
|
|
33
46
|
deleteFromSessionStorage: () => void;
|
|
@@ -40,6 +53,12 @@ export interface IChatbotUiBackendStore extends IChatUiBackendStore {
|
|
|
40
53
|
export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUiBackendStore {
|
|
41
54
|
session?: Models.ISession;
|
|
42
55
|
abortController!: AbortController;
|
|
56
|
+
|
|
57
|
+
/** Live progress for the in-flight streamed run (status line, activity log, steps, keepalive). */
|
|
58
|
+
readonly streamingProgress = new StreamingProgressModel();
|
|
59
|
+
/** Count of streaming → non-streaming fallbacks, tracked separately for regression detection. */
|
|
60
|
+
streamingFallbackCount = 0;
|
|
61
|
+
|
|
43
62
|
@inject(CHATBOT_API_CLIENT) protected readonly chatbotApiClient!: IChatbotApiClient;
|
|
44
63
|
@inject(CHATBOT_UI_STORE_TOKEN) protected readonly chatUiStore!: IChatbotUiStore;
|
|
45
64
|
|
|
@@ -112,7 +131,7 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
|
|
|
112
131
|
selections: Models.Selections | undefined,
|
|
113
132
|
timeoutMs?: number
|
|
114
133
|
) => {
|
|
115
|
-
await this.
|
|
134
|
+
await this.routeSend(messageModel, selections, timeoutMs);
|
|
116
135
|
resolve();
|
|
117
136
|
};
|
|
118
137
|
|
|
@@ -127,7 +146,7 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
|
|
|
127
146
|
}
|
|
128
147
|
const selections = messageModel.data?.selections as Models.Selections | undefined;
|
|
129
148
|
const timeoutMs = messageModel.data?.timeoutMs as number | undefined;
|
|
130
|
-
await this.
|
|
149
|
+
await this.routeSend(messageModel as ChatMessageModelText, selections, timeoutMs);
|
|
131
150
|
resolve();
|
|
132
151
|
};
|
|
133
152
|
|
|
@@ -299,6 +318,21 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
|
|
|
299
318
|
this.chatUiStore.addMessage(true, answer.answer, answer);
|
|
300
319
|
}
|
|
301
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Routes a send to the SSE streaming path when enabled (and supported by the client),
|
|
323
|
+
* otherwise to the existing non-streaming path. Default is non-streaming.
|
|
324
|
+
*/
|
|
325
|
+
protected routeSend(
|
|
326
|
+
messageModel: ChatMessageModelText,
|
|
327
|
+
selections?: Models.Selections,
|
|
328
|
+
timeoutMs?: number
|
|
329
|
+
): Promise<void> {
|
|
330
|
+
if (isChatbotStreamingEnabled(this.customizations) && this.chatbotApiClient.streamMessage) {
|
|
331
|
+
return this.sendMessageStreaming(messageModel, selections, timeoutMs);
|
|
332
|
+
}
|
|
333
|
+
return this.sendMessage(messageModel, selections, timeoutMs);
|
|
334
|
+
}
|
|
335
|
+
|
|
302
336
|
protected async sendMessage(
|
|
303
337
|
messageModel: ChatMessageModelText,
|
|
304
338
|
selections?: Models.Selections,
|
|
@@ -360,6 +394,147 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
|
|
|
360
394
|
}
|
|
361
395
|
}
|
|
362
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Streaming send-path: consumes the SSE agent-progress stream, rendering live progress and
|
|
399
|
+
* resolving the final answer on `run.finished`. Applies an inactivity-only timeout (no hard
|
|
400
|
+
* request timeout). Falls back transparently to {@link sendMessage} when the stream is
|
|
401
|
+
* unreachable at connect time — propagating the original per-message `timeoutMs` so the
|
|
402
|
+
* fallback behaves identically to the non-streaming path; surfaces a step error if the
|
|
403
|
+
* connection fails after connecting.
|
|
404
|
+
*/
|
|
405
|
+
protected async sendMessageStreaming(
|
|
406
|
+
messageModel: ChatMessageModelText,
|
|
407
|
+
selections?: Models.Selections,
|
|
408
|
+
timeoutMs?: number
|
|
409
|
+
) {
|
|
410
|
+
const question = messageModel.message;
|
|
411
|
+
const perRequestController = new AbortController();
|
|
412
|
+
const forwardStoreAbort = () =>
|
|
413
|
+
perRequestController.abort(this.abortController.signal.reason);
|
|
414
|
+
this.abortController.signal.addEventListener('abort', forwardStoreAbort, { once: true });
|
|
415
|
+
|
|
416
|
+
let connected = false;
|
|
417
|
+
try {
|
|
418
|
+
await this.startSession();
|
|
419
|
+
this.chatUiStore.setAgentTyping(true);
|
|
420
|
+
runInAction(() => this.streamingProgress.reset());
|
|
421
|
+
|
|
422
|
+
const inactivityTimeoutMs =
|
|
423
|
+
this.customizations?.streaming?.inactivityTimeoutMs ??
|
|
424
|
+
DEFAULT_STREAMING_INACTIVITY_TIMEOUT_MS;
|
|
425
|
+
|
|
426
|
+
// Apply a progress mutation, then scroll so the newest event stays in view.
|
|
427
|
+
const updateProgress = (mutate: () => void) => {
|
|
428
|
+
runInAction(mutate);
|
|
429
|
+
this.chatUiStore.triggerScroll();
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const handlers: AgentStreamHandlers = {
|
|
433
|
+
inactivityTimeoutMs,
|
|
434
|
+
onStatus: text =>
|
|
435
|
+
updateProgress(() => {
|
|
436
|
+
this.streamingProgress.clearKeepalive();
|
|
437
|
+
this.streamingProgress.setStatus(text);
|
|
438
|
+
}),
|
|
439
|
+
onText: text =>
|
|
440
|
+
updateProgress(() => {
|
|
441
|
+
this.streamingProgress.clearKeepalive();
|
|
442
|
+
this.streamingProgress.appendLog(text);
|
|
443
|
+
}),
|
|
444
|
+
onPlan: steps =>
|
|
445
|
+
updateProgress(() => {
|
|
446
|
+
this.streamingProgress.clearKeepalive();
|
|
447
|
+
this.streamingProgress.setSteps(steps);
|
|
448
|
+
}),
|
|
449
|
+
onStepActive: id =>
|
|
450
|
+
updateProgress(() => {
|
|
451
|
+
this.streamingProgress.clearKeepalive();
|
|
452
|
+
this.streamingProgress.setActiveStep(id);
|
|
453
|
+
}),
|
|
454
|
+
onInactivity: () =>
|
|
455
|
+
updateProgress(() =>
|
|
456
|
+
this.streamingProgress.setKeepalive(STREAMING_KEEPALIVE_TEXT)
|
|
457
|
+
),
|
|
458
|
+
onConnected: () => {
|
|
459
|
+
connected = true;
|
|
460
|
+
this.logStreaming('connected');
|
|
461
|
+
},
|
|
462
|
+
onDisconnected: () => this.logStreaming('disconnected'),
|
|
463
|
+
onTimeout: () => this.logStreaming('timed_out'),
|
|
464
|
+
onCompleted: () => this.logStreaming('completed'),
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const answer = await this.api(
|
|
468
|
+
'streamMessage',
|
|
469
|
+
new Models.UserMessage({
|
|
470
|
+
sessionId: this.session!.id!,
|
|
471
|
+
question,
|
|
472
|
+
experience: Models.Experience.MultiTurn,
|
|
473
|
+
selections,
|
|
474
|
+
}),
|
|
475
|
+
handlers,
|
|
476
|
+
perRequestController.signal
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
this.withSaveState(() => {
|
|
480
|
+
this.chatUiStore.setMessageState(messageModel, ChatMessageState.Delivered, {
|
|
481
|
+
answerId: answer.id,
|
|
482
|
+
selections,
|
|
483
|
+
});
|
|
484
|
+
this.processBotAnswer(answer);
|
|
485
|
+
this.chatUiStore.resetError(ChatRunState.Started);
|
|
486
|
+
});
|
|
487
|
+
/*
|
|
488
|
+
* Mark every plan step done and clear the transient keepalive; the rendered trail is
|
|
489
|
+
* faded by the UI once the final answer is shown. The next streamed send resets the model.
|
|
490
|
+
*/
|
|
491
|
+
runInAction(() => {
|
|
492
|
+
this.streamingProgress.completeAllSteps();
|
|
493
|
+
this.streamingProgress.clearKeepalive();
|
|
494
|
+
});
|
|
495
|
+
} catch (error: unknown) {
|
|
496
|
+
if (isAbortedError(error)) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Never connected → the endpoint was unreachable at connect time: fall back silently.
|
|
501
|
+
if (!connected) {
|
|
502
|
+
this.streamingFallbackCount += 1;
|
|
503
|
+
this.logStreaming('fallback');
|
|
504
|
+
runInAction(() => this.streamingProgress.reset());
|
|
505
|
+
await this.sendMessage(messageModel, selections, timeoutMs);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Connected then failed: surface the step error (the agent's message if provided).
|
|
510
|
+
this.withSaveState(() => {
|
|
511
|
+
this.chatUiStore.setMessageState(messageModel, ChatMessageState.Failed, {
|
|
512
|
+
selections,
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
runInAction(() => this.streamingProgress.reset());
|
|
516
|
+
const message =
|
|
517
|
+
error instanceof AgentStreamError ? error.message : STREAMING_STEP_ERROR;
|
|
518
|
+
if (error instanceof ChatError) {
|
|
519
|
+
this.setChatError('FailedToSendMessage', error);
|
|
520
|
+
} else {
|
|
521
|
+
this.setError('FailedToSendMessage', 'Send message', message, error);
|
|
522
|
+
}
|
|
523
|
+
} finally {
|
|
524
|
+
this.abortController.signal.removeEventListener('abort', forwardStoreAbort);
|
|
525
|
+
this.chatUiStore.setAgentTyping(false);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Logs a streaming connection lifecycle phase (connected, disconnected, timed_out, completed, fallback). */
|
|
530
|
+
protected logStreaming(phase: string): void {
|
|
531
|
+
this.logService.info({
|
|
532
|
+
message: `Streaming ${phase}`,
|
|
533
|
+
category: 'TitanChatbot',
|
|
534
|
+
code: `TitanChatbot_Streaming_${phase}`,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
363
538
|
protected setSession(session?: Models.ISession) {
|
|
364
539
|
this.session = session;
|
|
365
540
|
}
|
|
@@ -400,8 +575,8 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
|
|
|
400
575
|
|
|
401
576
|
protected async api<
|
|
402
577
|
K extends keyof IChatbotApiClient,
|
|
403
|
-
Args extends Parameters<IChatbotApiClient[K]
|
|
404
|
-
Result extends ReturnType<IChatbotApiClient[K]
|
|
578
|
+
Args extends Parameters<NonNullable<IChatbotApiClient[K]>>,
|
|
579
|
+
Result extends ReturnType<NonNullable<IChatbotApiClient[K]>>,
|
|
405
580
|
>(methodName: K, ...args: Args): Promise<Awaited<Result>> {
|
|
406
581
|
const method = this.chatbotApiClient[methodName];
|
|
407
582
|
if (method) {
|