@steerable/agent-ui 0.2.3 → 0.2.5
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/dist/cards/ActionSegmentCard.d.ts +18 -0
- package/dist/cards/ActionSegmentCard.d.ts.map +1 -0
- package/dist/cards/ActionSegmentCard.js +26 -0
- package/dist/cards/ActionSegmentCard.js.map +1 -0
- package/dist/cards/AnalysisDocumentCard.d.ts +33 -0
- package/dist/cards/AnalysisDocumentCard.d.ts.map +1 -0
- package/dist/cards/AnalysisDocumentCard.js +11 -0
- package/dist/cards/AnalysisDocumentCard.js.map +1 -0
- package/dist/cards/AskUserQuestionsCard.d.ts +43 -0
- package/dist/cards/AskUserQuestionsCard.d.ts.map +1 -0
- package/dist/cards/AskUserQuestionsCard.js +171 -0
- package/dist/cards/AskUserQuestionsCard.js.map +1 -0
- package/dist/cards/CoverageReportCard.d.ts +41 -0
- package/dist/cards/CoverageReportCard.d.ts.map +1 -0
- package/dist/cards/CoverageReportCard.js +55 -0
- package/dist/cards/CoverageReportCard.js.map +1 -0
- package/dist/cards/OrchestrationPlanCard.d.ts +60 -0
- package/dist/cards/OrchestrationPlanCard.d.ts.map +1 -0
- package/dist/cards/OrchestrationPlanCard.js +103 -0
- package/dist/cards/OrchestrationPlanCard.js.map +1 -0
- package/dist/cards/PlanSelectorCard.d.ts +40 -0
- package/dist/cards/PlanSelectorCard.d.ts.map +1 -0
- package/dist/cards/PlanSelectorCard.js +106 -0
- package/dist/cards/PlanSelectorCard.js.map +1 -0
- package/dist/cards/PlanStepsCard.d.ts +18 -0
- package/dist/cards/PlanStepsCard.d.ts.map +1 -0
- package/dist/cards/PlanStepsCard.js +25 -0
- package/dist/cards/PlanStepsCard.js.map +1 -0
- package/dist/cards/QuizCard.d.ts +44 -0
- package/dist/cards/QuizCard.d.ts.map +1 -0
- package/dist/cards/QuizCard.js +113 -0
- package/dist/cards/QuizCard.js.map +1 -0
- package/dist/cards/ResearchPlanCard.d.ts +23 -0
- package/dist/cards/ResearchPlanCard.d.ts.map +1 -0
- package/dist/cards/ResearchPlanCard.js +50 -0
- package/dist/cards/ResearchPlanCard.js.map +1 -0
- package/dist/cards/SearchSourcesCard.d.ts +29 -0
- package/dist/cards/SearchSourcesCard.d.ts.map +1 -0
- package/dist/cards/SearchSourcesCard.js +83 -0
- package/dist/cards/SearchSourcesCard.js.map +1 -0
- package/dist/cards/SuggestedRepliesCard.d.ts +18 -0
- package/dist/cards/SuggestedRepliesCard.d.ts.map +1 -0
- package/dist/cards/SuggestedRepliesCard.js +14 -0
- package/dist/cards/SuggestedRepliesCard.js.map +1 -0
- package/dist/cards/SummaryMessageCard.d.ts +40 -0
- package/dist/cards/SummaryMessageCard.d.ts.map +1 -0
- package/dist/cards/SummaryMessageCard.js +56 -0
- package/dist/cards/SummaryMessageCard.js.map +1 -0
- package/dist/cards/ThinkingProcessCard.d.ts +46 -0
- package/dist/cards/ThinkingProcessCard.d.ts.map +1 -0
- package/dist/cards/ThinkingProcessCard.js +79 -0
- package/dist/cards/ThinkingProcessCard.js.map +1 -0
- package/dist/cards/ToolExecutionCard.d.ts +21 -0
- package/dist/cards/ToolExecutionCard.d.ts.map +1 -0
- package/dist/cards/ToolExecutionCard.js +65 -0
- package/dist/cards/ToolExecutionCard.js.map +1 -0
- package/dist/cards/icons.d.ts +38 -0
- package/dist/cards/icons.d.ts.map +1 -0
- package/dist/cards/icons.js +87 -0
- package/dist/cards/icons.js.map +1 -0
- package/dist/cards/index.d.ts +38 -0
- package/dist/cards/index.d.ts.map +1 -0
- package/dist/cards/index.js +24 -0
- package/dist/cards/index.js.map +1 -0
- package/dist/cards/types.d.ts +24 -0
- package/dist/cards/types.d.ts.map +1 -0
- package/dist/cards/types.js +7 -0
- package/dist/cards/types.js.map +1 -0
- package/dist/components/AgentSelector.d.ts +23 -0
- package/dist/components/AgentSelector.d.ts.map +1 -0
- package/dist/components/AgentSelector.js +63 -0
- package/dist/components/AgentSelector.js.map +1 -0
- package/dist/components/AgentSelector.stories.d.ts +12 -0
- package/dist/components/AgentSelector.stories.d.ts.map +1 -0
- package/dist/components/AgentSelector.stories.js +95 -0
- package/dist/components/AgentSelector.stories.js.map +1 -0
- package/dist/components/ChatPanel.d.ts +138 -21
- package/dist/components/ChatPanel.d.ts.map +1 -1
- package/dist/components/ChatPanel.js +181 -26
- package/dist/components/ChatPanel.js.map +1 -1
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/hooks/useChatStream.d.ts +15 -0
- package/dist/hooks/useChatStream.d.ts.map +1 -1
- package/dist/hooks/useChatStream.js +13 -1
- package/dist/hooks/useChatStream.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/state/ChatSessionProvider.d.ts +59 -0
- package/dist/state/ChatSessionProvider.d.ts.map +1 -0
- package/dist/state/ChatSessionProvider.js +71 -0
- package/dist/state/ChatSessionProvider.js.map +1 -0
- package/dist/state/MockChatStreamTransport.d.ts +54 -0
- package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
- package/dist/state/MockChatStreamTransport.js +86 -0
- package/dist/state/MockChatStreamTransport.js.map +1 -0
- package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
- package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
- package/dist/state/MockChatStreamTransport.stories.js +60 -0
- package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
- package/dist/state/bridgeLegacySSE.d.ts +42 -0
- package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
- package/dist/state/bridgeLegacySSE.js +102 -0
- package/dist/state/bridgeLegacySSE.js.map +1 -0
- package/dist/state/index.d.ts +32 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +24 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/parseSSE.d.ts +50 -0
- package/dist/state/parseSSE.d.ts.map +1 -0
- package/dist/state/parseSSE.js +112 -0
- package/dist/state/parseSSE.js.map +1 -0
- package/dist/state/useChatComposer.d.ts +51 -0
- package/dist/state/useChatComposer.d.ts.map +1 -0
- package/dist/state/useChatComposer.js +82 -0
- package/dist/state/useChatComposer.js.map +1 -0
- package/dist/state/useChatList.d.ts +73 -0
- package/dist/state/useChatList.d.ts.map +1 -0
- package/dist/state/useChatList.js +161 -0
- package/dist/state/useChatList.js.map +1 -0
- package/dist/state/useChatSession.d.ts +29 -0
- package/dist/state/useChatSession.d.ts.map +1 -0
- package/dist/state/useChatSession.js +37 -0
- package/dist/state/useChatSession.js.map +1 -0
- package/dist/state/useScrollLock.d.ts +35 -0
- package/dist/state/useScrollLock.d.ts.map +1 -0
- package/dist/state/useScrollLock.js +74 -0
- package/dist/state/useScrollLock.js.map +1 -0
- package/dist/state/useToolCallStream.d.ts +38 -0
- package/dist/state/useToolCallStream.d.ts.map +1 -0
- package/dist/state/useToolCallStream.js +74 -0
- package/dist/state/useToolCallStream.js.map +1 -0
- package/package.json +11 -3
- package/src/cards/ActionSegmentCard.tsx +60 -0
- package/src/cards/AnalysisDocumentCard.tsx +81 -0
- package/src/cards/AskUserQuestionsCard.tsx +354 -0
- package/src/cards/CoverageReportCard.tsx +215 -0
- package/src/cards/OrchestrationPlanCard.tsx +328 -0
- package/src/cards/PlanSelectorCard.tsx +312 -0
- package/src/cards/PlanStepsCard.tsx +52 -0
- package/src/cards/QuizCard.tsx +309 -0
- package/src/cards/ResearchPlanCard.tsx +169 -0
- package/src/cards/SearchSourcesCard.tsx +244 -0
- package/src/cards/SuggestedRepliesCard.tsx +48 -0
- package/src/cards/SummaryMessageCard.tsx +145 -0
- package/src/cards/ThinkingProcessCard.tsx +155 -0
- package/src/cards/ToolExecutionCard.tsx +152 -0
- package/src/cards/cards.test.tsx +492 -0
- package/src/cards/icons.tsx +249 -0
- package/src/cards/index.ts +53 -0
- package/src/cards/types.ts +27 -0
- package/src/components/AgentSelector.stories.tsx +113 -0
- package/src/components/AgentSelector.test.tsx +70 -0
- package/src/components/AgentSelector.tsx +147 -0
- package/src/components/ChatPanel.test.tsx +130 -0
- package/src/components/ChatPanel.tsx +612 -138
- package/src/components/index.ts +13 -1
- package/src/hooks/useChatStream.test.ts +78 -0
- package/src/hooks/useChatStream.ts +32 -1
- package/src/index.ts +43 -0
- package/src/state/ChatSessionProvider.test.tsx +85 -0
- package/src/state/ChatSessionProvider.tsx +98 -0
- package/src/state/MockChatStreamTransport.stories.tsx +95 -0
- package/src/state/MockChatStreamTransport.test.ts +163 -0
- package/src/state/MockChatStreamTransport.ts +123 -0
- package/src/state/bridgeLegacySSE.test.ts +134 -0
- package/src/state/bridgeLegacySSE.ts +134 -0
- package/src/state/index.ts +82 -0
- package/src/state/parseSSE.test.ts +123 -0
- package/src/state/parseSSE.ts +123 -0
- package/src/state/useChatComposer.test.ts +145 -0
- package/src/state/useChatComposer.ts +117 -0
- package/src/state/useChatList.test.ts +135 -0
- package/src/state/useChatList.ts +220 -0
- package/src/state/useChatSession.test.ts +61 -0
- package/src/state/useChatSession.ts +64 -0
- package/src/state/useScrollLock.test.ts +99 -0
- package/src/state/useScrollLock.ts +105 -0
- package/src/state/useToolCallStream.test.ts +86 -0
- package/src/state/useToolCallStream.ts +110 -0
package/src/components/index.ts
CHANGED
|
@@ -2,7 +2,19 @@ export { MessageList } from './MessageList.js';
|
|
|
2
2
|
export type { MessageListProps, MessageRendererProps } from './MessageList.js';
|
|
3
3
|
|
|
4
4
|
export { ChatPanel } from './ChatPanel.js';
|
|
5
|
-
export type {
|
|
5
|
+
export type {
|
|
6
|
+
ChatPanelProps,
|
|
7
|
+
ChatPanelRootProps,
|
|
8
|
+
ChatPanelHeaderProps,
|
|
9
|
+
ChatPanelMessagesProps,
|
|
10
|
+
ChatPanelInputProps,
|
|
11
|
+
ChatPanelInputHandle,
|
|
12
|
+
ChatPanelEmptyProps,
|
|
13
|
+
ChatPanelStreamingStatusProps,
|
|
14
|
+
} from './ChatPanel.js';
|
|
15
|
+
|
|
16
|
+
export { AgentSelector } from './AgentSelector.js';
|
|
17
|
+
export type { AgentSelectorProps } from './AgentSelector.js';
|
|
6
18
|
|
|
7
19
|
export { OrchestrationPlanCard } from './OrchestrationPlanCard.js';
|
|
8
20
|
export type {
|
|
@@ -231,3 +231,81 @@ describe('useChatStream', () => {
|
|
|
231
231
|
expect(true).toBe(true);
|
|
232
232
|
});
|
|
233
233
|
});
|
|
234
|
+
|
|
235
|
+
describe('steerUserMessage', () => {
|
|
236
|
+
it('appends the user message when the transport accepts the steer', async () => {
|
|
237
|
+
const steer = vi.fn().mockResolvedValue(true);
|
|
238
|
+
const transport: ChatStreamTransport = {
|
|
239
|
+
stream: vi.fn(async (_input, onEvent) => {
|
|
240
|
+
onEvent({ type: 'content', content: 'working…' });
|
|
241
|
+
// keep the stream open until the test steers
|
|
242
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 30));
|
|
243
|
+
onEvent({ type: 'content', content: 'done' });
|
|
244
|
+
onEvent({ type: 'done' });
|
|
245
|
+
}),
|
|
246
|
+
steer,
|
|
247
|
+
};
|
|
248
|
+
const { result } = renderHook(() => useChatStream({ transport }));
|
|
249
|
+
|
|
250
|
+
let done: Promise<void>;
|
|
251
|
+
act(() => {
|
|
252
|
+
done = result.current.sendUserMessage({ content: 'start' });
|
|
253
|
+
});
|
|
254
|
+
await act(async () => {
|
|
255
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
let ok = false;
|
|
259
|
+
await act(async () => {
|
|
260
|
+
ok = await result.current.steerUserMessage('补充一句');
|
|
261
|
+
});
|
|
262
|
+
expect(ok).toBe(true);
|
|
263
|
+
expect(steer).toHaveBeenCalledWith('补充一句');
|
|
264
|
+
expect(
|
|
265
|
+
result.current.messages.some((m) => m.role === 'user' && m.content === '补充一句'),
|
|
266
|
+
).toBe(true);
|
|
267
|
+
|
|
268
|
+
await act(async () => {
|
|
269
|
+
await done;
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('returns false and appends nothing when not streaming', async () => {
|
|
274
|
+
const steer = vi.fn().mockResolvedValue(true);
|
|
275
|
+
const transport: ChatStreamTransport = {
|
|
276
|
+
stream: vi.fn(async () => {}),
|
|
277
|
+
steer,
|
|
278
|
+
};
|
|
279
|
+
const { result } = renderHook(() => useChatStream({ transport }));
|
|
280
|
+
|
|
281
|
+
let ok = true;
|
|
282
|
+
await act(async () => {
|
|
283
|
+
ok = await result.current.steerUserMessage('hello');
|
|
284
|
+
});
|
|
285
|
+
expect(ok).toBe(false);
|
|
286
|
+
expect(steer).not.toHaveBeenCalled();
|
|
287
|
+
expect(result.current.messages).toHaveLength(0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('returns false when the transport has no steer support', async () => {
|
|
291
|
+
const transport: ChatStreamTransport = {
|
|
292
|
+
stream: vi.fn(async () => {
|
|
293
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
|
294
|
+
}),
|
|
295
|
+
};
|
|
296
|
+
const { result } = renderHook(() => useChatStream({ transport }));
|
|
297
|
+
|
|
298
|
+
let done: Promise<void>;
|
|
299
|
+
act(() => {
|
|
300
|
+
done = result.current.sendUserMessage({ content: 'start' });
|
|
301
|
+
});
|
|
302
|
+
let ok = true;
|
|
303
|
+
await act(async () => {
|
|
304
|
+
ok = await result.current.steerUserMessage('mid');
|
|
305
|
+
});
|
|
306
|
+
expect(ok).toBe(false);
|
|
307
|
+
await act(async () => {
|
|
308
|
+
await done;
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -38,6 +38,14 @@ export interface ChatStreamTransport {
|
|
|
38
38
|
input: ChatStreamSendInput,
|
|
39
39
|
onEvent: (event: SSEEvent) => void,
|
|
40
40
|
) => Promise<void | (() => void)>;
|
|
41
|
+
/**
|
|
42
|
+
* Optional mid-turn steering: inject a user message into the currently
|
|
43
|
+
* running turn (CoreLoop `steer`). Resolve `true` when the running turn
|
|
44
|
+
* accepted the message; `false` when no steerable turn is active (the
|
|
45
|
+
* caller should keep the draft — a normal send can follow once the turn
|
|
46
|
+
* ends). When omitted, the UI keeps send-during-streaming disabled.
|
|
47
|
+
*/
|
|
48
|
+
steer?: (content: string) => Promise<boolean>;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
export interface UseChatStreamOptions {
|
|
@@ -63,6 +71,13 @@ export interface UseChatStreamReturn {
|
|
|
63
71
|
messages: ChatMessage[];
|
|
64
72
|
isStreaming: boolean;
|
|
65
73
|
sendUserMessage: (input: ChatStreamSendInput) => Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Steer the running turn with an extra user message. No-op resolving
|
|
76
|
+
* `false` when not streaming or the transport has no `steer`. On success
|
|
77
|
+
* the message is appended to the visible transcript immediately (the loop
|
|
78
|
+
* consumes it at the next round boundary).
|
|
79
|
+
*/
|
|
80
|
+
steerUserMessage: (content: string) => Promise<boolean>;
|
|
66
81
|
cancel: () => void;
|
|
67
82
|
/** Replace the message buffer (e.g. when switching chat). */
|
|
68
83
|
setMessages: (messages: ChatMessage[]) => void;
|
|
@@ -310,6 +325,21 @@ export function useChatStream(
|
|
|
310
325
|
setStreaming(false);
|
|
311
326
|
}, [setStreaming]);
|
|
312
327
|
|
|
328
|
+
const steerUserMessage = useCallback(
|
|
329
|
+
async (content: string): Promise<boolean> => {
|
|
330
|
+
const steer = options.transport.steer;
|
|
331
|
+
if (!isStreamingRef.current || !steer || !content.trim()) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const ok = await steer(content);
|
|
335
|
+
if (ok) {
|
|
336
|
+
dispatch({ type: 'append', message: newUserMessage(content) });
|
|
337
|
+
}
|
|
338
|
+
return ok;
|
|
339
|
+
},
|
|
340
|
+
[options.transport],
|
|
341
|
+
);
|
|
342
|
+
|
|
313
343
|
const setMessages = useCallback((messages: ChatMessage[]) => {
|
|
314
344
|
dispatch({ type: 'reset', messages });
|
|
315
345
|
}, []);
|
|
@@ -332,10 +362,11 @@ export function useChatStream(
|
|
|
332
362
|
messages: state.messages,
|
|
333
363
|
isStreaming: isStreamingRef.current,
|
|
334
364
|
sendUserMessage,
|
|
365
|
+
steerUserMessage,
|
|
335
366
|
cancel,
|
|
336
367
|
setMessages,
|
|
337
368
|
appendMessage,
|
|
338
369
|
}),
|
|
339
|
-
[state.messages, sendUserMessage, cancel, setMessages, appendMessage],
|
|
370
|
+
[state.messages, sendUserMessage, steerUserMessage, cancel, setMessages, appendMessage],
|
|
340
371
|
);
|
|
341
372
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,3 +10,46 @@
|
|
|
10
10
|
|
|
11
11
|
export * from './hooks/index.js';
|
|
12
12
|
export * from './components/index.js';
|
|
13
|
+
|
|
14
|
+
// `state/` is the home for chat-session primitives (hooks + transports +
|
|
15
|
+
// SSE parsing) lifted out of deeppath / deeppath-agent. The package root
|
|
16
|
+
// re-exports the public surface for convenience; the `./state` subpath is the
|
|
17
|
+
// canonical entrypoint for tree-shaking.
|
|
18
|
+
export {
|
|
19
|
+
SSEParser,
|
|
20
|
+
parseSSEData,
|
|
21
|
+
bridgeLegacySSE,
|
|
22
|
+
MockChatStreamTransport,
|
|
23
|
+
useChatComposer,
|
|
24
|
+
useChatList,
|
|
25
|
+
useChatSession,
|
|
26
|
+
useToolCallStream,
|
|
27
|
+
useScrollLock,
|
|
28
|
+
ChatSessionProvider,
|
|
29
|
+
useChatSessionContext,
|
|
30
|
+
useOptionalChatSession,
|
|
31
|
+
useChatSessionSlice,
|
|
32
|
+
} from './state/index.js';
|
|
33
|
+
export type {
|
|
34
|
+
SSEFrame,
|
|
35
|
+
SSEParserOptions,
|
|
36
|
+
EnvelopeProfile,
|
|
37
|
+
BridgeLegacySSEOptions,
|
|
38
|
+
MockScript,
|
|
39
|
+
MockScriptStep,
|
|
40
|
+
MockChatStreamTransportOptions,
|
|
41
|
+
UseChatComposerOptions,
|
|
42
|
+
UseChatComposerReturn,
|
|
43
|
+
ChatListTransport,
|
|
44
|
+
UseChatListOptions,
|
|
45
|
+
UseChatListReturn,
|
|
46
|
+
UseChatSessionOptions,
|
|
47
|
+
UseChatSessionReturn,
|
|
48
|
+
ToolCallEntry,
|
|
49
|
+
UseToolCallStreamOptions,
|
|
50
|
+
UseToolCallStreamReturn,
|
|
51
|
+
UseScrollLockOptions,
|
|
52
|
+
UseScrollLockReturn,
|
|
53
|
+
ChatSessionContextValue,
|
|
54
|
+
ChatSessionProviderProps,
|
|
55
|
+
} from './state/index.js';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for `ChatSessionProvider`. Verifies that the provider exposes
|
|
3
|
+
* the active session, that `useChatSessionContext` throws outside it, and
|
|
4
|
+
* that the optional / slice variants degrade gracefully.
|
|
5
|
+
*/
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
import { render, screen, act, fireEvent } from '@testing-library/react';
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ChatSessionProvider,
|
|
12
|
+
useChatSessionContext,
|
|
13
|
+
useOptionalChatSession,
|
|
14
|
+
useChatSessionSlice,
|
|
15
|
+
useChatSession,
|
|
16
|
+
} from './index.js';
|
|
17
|
+
import { MockChatStreamTransport } from './MockChatStreamTransport.js';
|
|
18
|
+
|
|
19
|
+
function Probe({ onValue }: { onValue: (v: unknown) => void }) {
|
|
20
|
+
const ctx = useChatSessionContext();
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
onValue({ messages: ctx.messages.length, isStreaming: ctx.isStreaming });
|
|
23
|
+
}, [ctx.messages.length, ctx.isStreaming, onValue]);
|
|
24
|
+
return <span data-testid="ok">ok</span>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function OptionalProbe({ onResult }: { onResult: (v: unknown) => void }) {
|
|
28
|
+
const ctx = useOptionalChatSession();
|
|
29
|
+
React.useEffect(() => onResult(ctx), [ctx, onResult]);
|
|
30
|
+
return <span>maybe</span>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function SliceProbe({ onValue }: { onValue: (v: unknown) => void }) {
|
|
34
|
+
const slice = useChatSessionSlice();
|
|
35
|
+
React.useEffect(() => onValue(slice), [slice, onValue]);
|
|
36
|
+
return <span>slice</span>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Host({ children }: { children: React.ReactNode }) {
|
|
40
|
+
const transport = React.useMemo(
|
|
41
|
+
() =>
|
|
42
|
+
new MockChatStreamTransport({
|
|
43
|
+
script: [
|
|
44
|
+
{ event: { type: 'message.delta', role: 'assistant', delta: 'hi' } },
|
|
45
|
+
{ event: { type: 'message.complete' } },
|
|
46
|
+
],
|
|
47
|
+
}),
|
|
48
|
+
[],
|
|
49
|
+
);
|
|
50
|
+
const session = useChatSession({ transport });
|
|
51
|
+
return <ChatSessionProvider value={session}>{children}</ChatSessionProvider>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('ChatSessionProvider', () => {
|
|
55
|
+
it('exposes the session via useChatSessionContext', () => {
|
|
56
|
+
const seen = vi.fn();
|
|
57
|
+
render(
|
|
58
|
+
<Host>
|
|
59
|
+
<Probe onValue={seen} />
|
|
60
|
+
</Host>,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.getByTestId('ok')).toBeTruthy();
|
|
63
|
+
expect(seen).toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('useChatSessionContext throws outside provider', () => {
|
|
67
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
68
|
+
expect(() => {
|
|
69
|
+
render(<Probe onValue={vi.fn()} />);
|
|
70
|
+
}).toThrow(/inside a <ChatSessionProvider/);
|
|
71
|
+
consoleError.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('useOptionalChatSession returns null outside provider', () => {
|
|
75
|
+
const seen = vi.fn();
|
|
76
|
+
render(<OptionalProbe onResult={seen} />);
|
|
77
|
+
expect(seen).toHaveBeenCalledWith(null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('useChatSessionSlice returns null outside provider', () => {
|
|
81
|
+
const seen = vi.fn();
|
|
82
|
+
render(<SliceProbe onValue={seen} />);
|
|
83
|
+
expect(seen).toHaveBeenCalledWith(null);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ChatSessionProvider` + `useChatSessionContext`
|
|
3
|
+
*
|
|
4
|
+
* Lifts the chat-session primitives out of the host app's god-context (e.g.
|
|
5
|
+
* deeppath's `ChatUIContext`, deeppath-agent's hooks) into a framework-owned
|
|
6
|
+
* provider. Concretely it owns:
|
|
7
|
+
*
|
|
8
|
+
* - the streaming transcript: `messages`, `isStreaming`, `streamingMessageId`
|
|
9
|
+
* - send / cancel / clear actions
|
|
10
|
+
* - the composer (draft, send, cancel, key handlers)
|
|
11
|
+
*
|
|
12
|
+
* App-specific concerns that don't belong here (file uploads, geolocation,
|
|
13
|
+
* suggestion banners, chat tool toggles, chat list / agent selector) stay in
|
|
14
|
+
* the host app's own context. The `extras` slot lets apps thread custom
|
|
15
|
+
* values through the same provider so chat cards can call host actions
|
|
16
|
+
* without prop-drilling.
|
|
17
|
+
*
|
|
18
|
+
* Usage in a host app:
|
|
19
|
+
*
|
|
20
|
+
* <ChatSessionProvider
|
|
21
|
+
* value={{ ...useChatSession({ transport }), extras: { ...appHooks } }}
|
|
22
|
+
* >
|
|
23
|
+
* <ChatPanel.Root>...</ChatPanel.Root>
|
|
24
|
+
* </ChatSessionProvider>
|
|
25
|
+
*
|
|
26
|
+
* Cards / shell pieces consume via `useChatSessionContext()` so they don't
|
|
27
|
+
* import the host's context directly. This is the seam that lets the same
|
|
28
|
+
* compound `ChatPanel` work in deeppath, deeppath-agent, and the framework
|
|
29
|
+
* `examples/web-shell`.
|
|
30
|
+
*/
|
|
31
|
+
import * as React from 'react';
|
|
32
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
33
|
+
import type { UseChatSessionReturn } from './useChatSession.js';
|
|
34
|
+
|
|
35
|
+
export interface ChatSessionContextValue<TExtras = unknown> extends UseChatSessionReturn {
|
|
36
|
+
/** Host-specific values threaded through the same provider (geo, files, ...). */
|
|
37
|
+
extras?: TExtras;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ChatSessionContext = React.createContext<ChatSessionContextValue | null>(null);
|
|
41
|
+
|
|
42
|
+
export interface ChatSessionProviderProps<TExtras = unknown> {
|
|
43
|
+
value: ChatSessionContextValue<TExtras>;
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ChatSessionProvider<TExtras = unknown>({
|
|
48
|
+
value,
|
|
49
|
+
children,
|
|
50
|
+
}: ChatSessionProviderProps<TExtras>) {
|
|
51
|
+
return (
|
|
52
|
+
<ChatSessionContext.Provider value={value as ChatSessionContextValue}>
|
|
53
|
+
{children}
|
|
54
|
+
</ChatSessionContext.Provider>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read the active chat session. Throws if no `ChatSessionProvider` is
|
|
60
|
+
* mounted above -- this is intentional: cards / shell pieces should be
|
|
61
|
+
* usable both inside the provider (typical) and via direct props for
|
|
62
|
+
* apps that haven't migrated yet (use `useOptionalChatSession()` for that).
|
|
63
|
+
*/
|
|
64
|
+
export function useChatSessionContext<TExtras = unknown>(): ChatSessionContextValue<TExtras> {
|
|
65
|
+
const ctx = React.useContext(ChatSessionContext);
|
|
66
|
+
if (!ctx) {
|
|
67
|
+
throw new Error('useChatSessionContext must be used inside a <ChatSessionProvider />');
|
|
68
|
+
}
|
|
69
|
+
return ctx as ChatSessionContextValue<TExtras>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Non-throwing variant for components that want to fall back to props. */
|
|
73
|
+
export function useOptionalChatSession<TExtras = unknown>():
|
|
74
|
+
| ChatSessionContextValue<TExtras>
|
|
75
|
+
| null {
|
|
76
|
+
const ctx = React.useContext(ChatSessionContext);
|
|
77
|
+
return ctx as ChatSessionContextValue<TExtras> | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Convenience selector for the most-used slice in cards (`{ messages,
|
|
82
|
+
* isStreaming, sendUserMessage, cancel }`). Returns null when there's no
|
|
83
|
+
* provider so cards can opt into context-driven mode incrementally.
|
|
84
|
+
*/
|
|
85
|
+
export function useChatSessionSlice():
|
|
86
|
+
| Pick<ChatSessionContextValue, 'messages' | 'isStreaming' | 'sendUserMessage' | 'cancel'>
|
|
87
|
+
| null {
|
|
88
|
+
const ctx = useOptionalChatSession();
|
|
89
|
+
if (!ctx) return null;
|
|
90
|
+
return {
|
|
91
|
+
messages: ctx.messages,
|
|
92
|
+
isStreaming: ctx.isStreaming,
|
|
93
|
+
sendUserMessage: ctx.sendUserMessage,
|
|
94
|
+
cancel: ctx.cancel,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type { ChatMessage };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storybook story for `MockChatStreamTransport` paired with `useChatSession`.
|
|
3
|
+
* This is the canonical "framework runs end-to-end with zero backend" demo —
|
|
4
|
+
* the same pattern is used by `examples/web-shell` in wave 5.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
8
|
+
import { useMemo } from 'react';
|
|
9
|
+
import type { SSEEvent } from '@steerable/agent-protocol';
|
|
10
|
+
import { ChatPanel } from '../components/ChatPanel.js';
|
|
11
|
+
import { MockChatStreamTransport } from './MockChatStreamTransport.js';
|
|
12
|
+
import { useChatSession } from './useChatSession.js';
|
|
13
|
+
|
|
14
|
+
const meta: Meta = {
|
|
15
|
+
title: 'State/MockChatStreamTransport',
|
|
16
|
+
parameters: {
|
|
17
|
+
layout: 'fullscreen',
|
|
18
|
+
docs: {
|
|
19
|
+
description: {
|
|
20
|
+
component:
|
|
21
|
+
'Scripted SSE transport used in tests, stories, and the framework web-shell example. Pair with `useChatSession` for a fully offline chat demo.',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj;
|
|
28
|
+
|
|
29
|
+
const baselineScript: SSEEvent[] = [
|
|
30
|
+
{ type: 'content', content: 'Sure — ' },
|
|
31
|
+
{ type: 'content', content: "here's a plan:\n\n" },
|
|
32
|
+
{ type: 'content', content: '1. Outline goals\n' },
|
|
33
|
+
{ type: 'content', content: '2. Gather inputs\n' },
|
|
34
|
+
{ type: 'content', content: '3. Iterate\n\n' },
|
|
35
|
+
{ type: 'content', content: 'Want me to dive deeper into any step?' },
|
|
36
|
+
{ type: 'done' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const toolCallScript: SSEEvent[] = [
|
|
40
|
+
{ type: 'content', content: 'Checking the weather…\n' },
|
|
41
|
+
{
|
|
42
|
+
type: 'tool_call',
|
|
43
|
+
payload: { id: 'c1', name: 'get_weather', arguments: { city: 'Tokyo' } },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'tool_result',
|
|
47
|
+
payload: { success: true, data: { temp: 22, conditions: 'clear' } },
|
|
48
|
+
},
|
|
49
|
+
{ type: 'content', content: '\nIt is 22°C and clear in Tokyo right now.' },
|
|
50
|
+
{ type: 'done' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function DemoChat({ scripts, delayMs }: { scripts: SSEEvent[][]; delayMs: number }) {
|
|
54
|
+
const transport = useMemo(
|
|
55
|
+
() =>
|
|
56
|
+
new MockChatStreamTransport({
|
|
57
|
+
scripts,
|
|
58
|
+
defaultDelayMs: delayMs,
|
|
59
|
+
exhaustionPolicy: 'cycle',
|
|
60
|
+
}),
|
|
61
|
+
[scripts, delayMs],
|
|
62
|
+
);
|
|
63
|
+
const session = useChatSession({ transport });
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="h-[640px] w-full max-w-3xl border border-agent-border bg-agent-canvas">
|
|
67
|
+
<ChatPanel
|
|
68
|
+
messages={session.messages}
|
|
69
|
+
isStreaming={session.isStreaming}
|
|
70
|
+
onSubmit={async ({ content }) => {
|
|
71
|
+
await session.sendUserMessage({ content });
|
|
72
|
+
}}
|
|
73
|
+
onCancel={session.cancel}
|
|
74
|
+
emptyState={
|
|
75
|
+
<div className="m-auto max-w-sm text-center text-sm text-agent-muted-foreground">
|
|
76
|
+
<p className="font-medium text-agent-foreground">Try the mock transport</p>
|
|
77
|
+
<p className="mt-1">Anything you type triggers the next scripted reply.</p>
|
|
78
|
+
</div>
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const ContentOnly: Story = {
|
|
86
|
+
render: () => <DemoChat scripts={[baselineScript]} delayMs={30} />,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const WithToolCall: Story = {
|
|
90
|
+
render: () => <DemoChat scripts={[toolCallScript]} delayMs={30} />,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const Cycling: Story = {
|
|
94
|
+
render: () => <DemoChat scripts={[baselineScript, toolCallScript]} delayMs={30} />,
|
|
95
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `MockChatStreamTransport`.
|
|
3
|
+
*
|
|
4
|
+
* The mock is the source of all canned demos / stories / fixtures, so a
|
|
5
|
+
* regression here cascades. We cover: per-turn script rotation, exhaustion
|
|
6
|
+
* policies, the cancel handle, and per-step delays.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { MockChatStreamTransport } from './MockChatStreamTransport';
|
|
11
|
+
|
|
12
|
+
describe('MockChatStreamTransport', () => {
|
|
13
|
+
it('replays the script for the current turn', async () => {
|
|
14
|
+
const onEvent = vi.fn();
|
|
15
|
+
const transport = new MockChatStreamTransport({
|
|
16
|
+
scripts: [[
|
|
17
|
+
{ type: 'content', content: 'a' },
|
|
18
|
+
{ type: 'content', content: 'b' },
|
|
19
|
+
{ type: 'done' },
|
|
20
|
+
]],
|
|
21
|
+
});
|
|
22
|
+
await transport.stream({ content: 'hi' }, onEvent);
|
|
23
|
+
// The mock dispatches synchronously when defaultDelayMs is 0 — but it
|
|
24
|
+
// still goes through a microtask queue inside `run()`. Flush.
|
|
25
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
26
|
+
expect(onEvent).toHaveBeenCalledTimes(3);
|
|
27
|
+
expect(onEvent.mock.calls.map((c) => c[0].type)).toEqual([
|
|
28
|
+
'content',
|
|
29
|
+
'content',
|
|
30
|
+
'done',
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('cycles scripts when more turns than scripts exist (default)', async () => {
|
|
35
|
+
const transport = new MockChatStreamTransport({
|
|
36
|
+
scripts: [
|
|
37
|
+
[{ type: 'content', content: 'a' }, { type: 'done' }],
|
|
38
|
+
[{ type: 'content', content: 'b' }, { type: 'done' }],
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
const sink: string[] = [];
|
|
42
|
+
const onEvent = (e: any) => {
|
|
43
|
+
if (e.type === 'content') sink.push(e.content);
|
|
44
|
+
};
|
|
45
|
+
await transport.stream({ content: '' }, onEvent);
|
|
46
|
+
await transport.stream({ content: '' }, onEvent);
|
|
47
|
+
await transport.stream({ content: '' }, onEvent);
|
|
48
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
49
|
+
expect(sink).toEqual(['a', 'b', 'a']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('exhaustionPolicy=last keeps emitting the last script', async () => {
|
|
53
|
+
const transport = new MockChatStreamTransport({
|
|
54
|
+
scripts: [
|
|
55
|
+
[{ type: 'content', content: 'first' }, { type: 'done' }],
|
|
56
|
+
[{ type: 'content', content: 'last' }, { type: 'done' }],
|
|
57
|
+
],
|
|
58
|
+
exhaustionPolicy: 'last',
|
|
59
|
+
});
|
|
60
|
+
const sink: string[] = [];
|
|
61
|
+
const onEvent = (e: any) => {
|
|
62
|
+
if (e.type === 'content') sink.push(e.content);
|
|
63
|
+
};
|
|
64
|
+
await transport.stream({ content: '' }, onEvent);
|
|
65
|
+
await transport.stream({ content: '' }, onEvent);
|
|
66
|
+
await transport.stream({ content: '' }, onEvent);
|
|
67
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
68
|
+
expect(sink).toEqual(['first', 'last', 'last']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('exhaustionPolicy=empty emits a bare done event', async () => {
|
|
72
|
+
const transport = new MockChatStreamTransport({
|
|
73
|
+
scripts: [[{ type: 'content', content: 'one' }, { type: 'done' }]],
|
|
74
|
+
exhaustionPolicy: 'empty',
|
|
75
|
+
});
|
|
76
|
+
const events: string[] = [];
|
|
77
|
+
await transport.stream({ content: '' }, (e) => events.push(e.type));
|
|
78
|
+
await transport.stream({ content: '' }, (e) => events.push(e.type));
|
|
79
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
80
|
+
expect(events).toEqual(['content', 'done', 'done']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('accepts a callback in place of a script array', async () => {
|
|
84
|
+
const transport = new MockChatStreamTransport({
|
|
85
|
+
scripts: (turn, input) => [
|
|
86
|
+
{ type: 'content', content: `${input.content}:${turn}` },
|
|
87
|
+
{ type: 'done' },
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
const sink: string[] = [];
|
|
91
|
+
await transport.stream({ content: 'hi' }, (e) => {
|
|
92
|
+
if (e.type === 'content') sink.push(e.content!);
|
|
93
|
+
});
|
|
94
|
+
await transport.stream({ content: 'yo' }, (e) => {
|
|
95
|
+
if (e.type === 'content') sink.push(e.content!);
|
|
96
|
+
});
|
|
97
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
98
|
+
expect(sink).toEqual(['hi:0', 'yo:1']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('honours per-step delayMs', async () => {
|
|
102
|
+
vi.useFakeTimers();
|
|
103
|
+
try {
|
|
104
|
+
const transport = new MockChatStreamTransport({
|
|
105
|
+
scripts: [[
|
|
106
|
+
{ event: { type: 'content', content: 'fast' } },
|
|
107
|
+
{ event: { type: 'content', content: 'slow' }, delayMs: 100 },
|
|
108
|
+
{ type: 'done' },
|
|
109
|
+
]],
|
|
110
|
+
});
|
|
111
|
+
const events: any[] = [];
|
|
112
|
+
const p = transport.stream({ content: '' }, (e) => events.push(e));
|
|
113
|
+
await p;
|
|
114
|
+
// After the immediate microtask: first event delivered.
|
|
115
|
+
await Promise.resolve();
|
|
116
|
+
expect(events.length).toBeLessThanOrEqual(2);
|
|
117
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
118
|
+
// After the delay: all events delivered.
|
|
119
|
+
expect(events.length).toBe(3);
|
|
120
|
+
expect(events[1].content).toBe('slow');
|
|
121
|
+
} finally {
|
|
122
|
+
vi.useRealTimers();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('cancel handle stops further events', async () => {
|
|
127
|
+
const transport = new MockChatStreamTransport({
|
|
128
|
+
scripts: [[
|
|
129
|
+
{ event: { type: 'content', content: 'a' }, delayMs: 10 },
|
|
130
|
+
{ event: { type: 'content', content: 'b' }, delayMs: 10 },
|
|
131
|
+
{ type: 'done' },
|
|
132
|
+
]],
|
|
133
|
+
});
|
|
134
|
+
const events: any[] = [];
|
|
135
|
+
const cancel = await transport.stream({ content: '' }, (e) => events.push(e));
|
|
136
|
+
cancel();
|
|
137
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
138
|
+
// Cancel should have prevented at least one of the delayed events from
|
|
139
|
+
// arriving. We don't pin the exact count (timing-dependent), just the
|
|
140
|
+
// strict upper bound.
|
|
141
|
+
expect(events.length).toBeLessThan(3);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('reset() rewinds the turn counter', async () => {
|
|
145
|
+
const transport = new MockChatStreamTransport({
|
|
146
|
+
scripts: [
|
|
147
|
+
[{ type: 'content', content: 'a' }, { type: 'done' }],
|
|
148
|
+
[{ type: 'content', content: 'b' }, { type: 'done' }],
|
|
149
|
+
],
|
|
150
|
+
exhaustionPolicy: 'empty',
|
|
151
|
+
});
|
|
152
|
+
const sink: string[] = [];
|
|
153
|
+
await transport.stream({ content: '' }, (e) => {
|
|
154
|
+
if (e.type === 'content') sink.push(e.content!);
|
|
155
|
+
});
|
|
156
|
+
transport.reset();
|
|
157
|
+
await transport.stream({ content: '' }, (e) => {
|
|
158
|
+
if (e.type === 'content') sink.push(e.content!);
|
|
159
|
+
});
|
|
160
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
161
|
+
expect(sink).toEqual(['a', 'a']);
|
|
162
|
+
});
|
|
163
|
+
});
|