@steerable/agent-ui 0.2.3 → 0.2.4

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.
Files changed (178) hide show
  1. package/dist/cards/ActionSegmentCard.d.ts +18 -0
  2. package/dist/cards/ActionSegmentCard.d.ts.map +1 -0
  3. package/dist/cards/ActionSegmentCard.js +26 -0
  4. package/dist/cards/ActionSegmentCard.js.map +1 -0
  5. package/dist/cards/AnalysisDocumentCard.d.ts +33 -0
  6. package/dist/cards/AnalysisDocumentCard.d.ts.map +1 -0
  7. package/dist/cards/AnalysisDocumentCard.js +11 -0
  8. package/dist/cards/AnalysisDocumentCard.js.map +1 -0
  9. package/dist/cards/AskUserQuestionsCard.d.ts +43 -0
  10. package/dist/cards/AskUserQuestionsCard.d.ts.map +1 -0
  11. package/dist/cards/AskUserQuestionsCard.js +171 -0
  12. package/dist/cards/AskUserQuestionsCard.js.map +1 -0
  13. package/dist/cards/CoverageReportCard.d.ts +41 -0
  14. package/dist/cards/CoverageReportCard.d.ts.map +1 -0
  15. package/dist/cards/CoverageReportCard.js +55 -0
  16. package/dist/cards/CoverageReportCard.js.map +1 -0
  17. package/dist/cards/OrchestrationPlanCard.d.ts +60 -0
  18. package/dist/cards/OrchestrationPlanCard.d.ts.map +1 -0
  19. package/dist/cards/OrchestrationPlanCard.js +103 -0
  20. package/dist/cards/OrchestrationPlanCard.js.map +1 -0
  21. package/dist/cards/PlanSelectorCard.d.ts +40 -0
  22. package/dist/cards/PlanSelectorCard.d.ts.map +1 -0
  23. package/dist/cards/PlanSelectorCard.js +106 -0
  24. package/dist/cards/PlanSelectorCard.js.map +1 -0
  25. package/dist/cards/PlanStepsCard.d.ts +18 -0
  26. package/dist/cards/PlanStepsCard.d.ts.map +1 -0
  27. package/dist/cards/PlanStepsCard.js +25 -0
  28. package/dist/cards/PlanStepsCard.js.map +1 -0
  29. package/dist/cards/QuizCard.d.ts +44 -0
  30. package/dist/cards/QuizCard.d.ts.map +1 -0
  31. package/dist/cards/QuizCard.js +113 -0
  32. package/dist/cards/QuizCard.js.map +1 -0
  33. package/dist/cards/ResearchPlanCard.d.ts +23 -0
  34. package/dist/cards/ResearchPlanCard.d.ts.map +1 -0
  35. package/dist/cards/ResearchPlanCard.js +50 -0
  36. package/dist/cards/ResearchPlanCard.js.map +1 -0
  37. package/dist/cards/SearchSourcesCard.d.ts +29 -0
  38. package/dist/cards/SearchSourcesCard.d.ts.map +1 -0
  39. package/dist/cards/SearchSourcesCard.js +83 -0
  40. package/dist/cards/SearchSourcesCard.js.map +1 -0
  41. package/dist/cards/SuggestedRepliesCard.d.ts +18 -0
  42. package/dist/cards/SuggestedRepliesCard.d.ts.map +1 -0
  43. package/dist/cards/SuggestedRepliesCard.js +14 -0
  44. package/dist/cards/SuggestedRepliesCard.js.map +1 -0
  45. package/dist/cards/SummaryMessageCard.d.ts +40 -0
  46. package/dist/cards/SummaryMessageCard.d.ts.map +1 -0
  47. package/dist/cards/SummaryMessageCard.js +56 -0
  48. package/dist/cards/SummaryMessageCard.js.map +1 -0
  49. package/dist/cards/ThinkingProcessCard.d.ts +46 -0
  50. package/dist/cards/ThinkingProcessCard.d.ts.map +1 -0
  51. package/dist/cards/ThinkingProcessCard.js +79 -0
  52. package/dist/cards/ThinkingProcessCard.js.map +1 -0
  53. package/dist/cards/ToolExecutionCard.d.ts +21 -0
  54. package/dist/cards/ToolExecutionCard.d.ts.map +1 -0
  55. package/dist/cards/ToolExecutionCard.js +65 -0
  56. package/dist/cards/ToolExecutionCard.js.map +1 -0
  57. package/dist/cards/icons.d.ts +38 -0
  58. package/dist/cards/icons.d.ts.map +1 -0
  59. package/dist/cards/icons.js +87 -0
  60. package/dist/cards/icons.js.map +1 -0
  61. package/dist/cards/index.d.ts +38 -0
  62. package/dist/cards/index.d.ts.map +1 -0
  63. package/dist/cards/index.js +24 -0
  64. package/dist/cards/index.js.map +1 -0
  65. package/dist/cards/types.d.ts +24 -0
  66. package/dist/cards/types.d.ts.map +1 -0
  67. package/dist/cards/types.js +7 -0
  68. package/dist/cards/types.js.map +1 -0
  69. package/dist/components/AgentSelector.d.ts +23 -0
  70. package/dist/components/AgentSelector.d.ts.map +1 -0
  71. package/dist/components/AgentSelector.js +63 -0
  72. package/dist/components/AgentSelector.js.map +1 -0
  73. package/dist/components/AgentSelector.stories.d.ts +12 -0
  74. package/dist/components/AgentSelector.stories.d.ts.map +1 -0
  75. package/dist/components/AgentSelector.stories.js +95 -0
  76. package/dist/components/AgentSelector.stories.js.map +1 -0
  77. package/dist/components/ChatPanel.d.ts +138 -21
  78. package/dist/components/ChatPanel.d.ts.map +1 -1
  79. package/dist/components/ChatPanel.js +181 -26
  80. package/dist/components/ChatPanel.js.map +1 -1
  81. package/dist/components/index.d.ts +3 -1
  82. package/dist/components/index.d.ts.map +1 -1
  83. package/dist/components/index.js +1 -0
  84. package/dist/components/index.js.map +1 -1
  85. package/dist/index.d.ts +2 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +5 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/state/ChatSessionProvider.d.ts +59 -0
  90. package/dist/state/ChatSessionProvider.d.ts.map +1 -0
  91. package/dist/state/ChatSessionProvider.js +71 -0
  92. package/dist/state/ChatSessionProvider.js.map +1 -0
  93. package/dist/state/MockChatStreamTransport.d.ts +54 -0
  94. package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
  95. package/dist/state/MockChatStreamTransport.js +86 -0
  96. package/dist/state/MockChatStreamTransport.js.map +1 -0
  97. package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
  98. package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
  99. package/dist/state/MockChatStreamTransport.stories.js +60 -0
  100. package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
  101. package/dist/state/bridgeLegacySSE.d.ts +42 -0
  102. package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
  103. package/dist/state/bridgeLegacySSE.js +102 -0
  104. package/dist/state/bridgeLegacySSE.js.map +1 -0
  105. package/dist/state/index.d.ts +32 -0
  106. package/dist/state/index.d.ts.map +1 -0
  107. package/dist/state/index.js +24 -0
  108. package/dist/state/index.js.map +1 -0
  109. package/dist/state/parseSSE.d.ts +50 -0
  110. package/dist/state/parseSSE.d.ts.map +1 -0
  111. package/dist/state/parseSSE.js +112 -0
  112. package/dist/state/parseSSE.js.map +1 -0
  113. package/dist/state/useChatComposer.d.ts +51 -0
  114. package/dist/state/useChatComposer.d.ts.map +1 -0
  115. package/dist/state/useChatComposer.js +82 -0
  116. package/dist/state/useChatComposer.js.map +1 -0
  117. package/dist/state/useChatList.d.ts +71 -0
  118. package/dist/state/useChatList.d.ts.map +1 -0
  119. package/dist/state/useChatList.js +158 -0
  120. package/dist/state/useChatList.js.map +1 -0
  121. package/dist/state/useChatSession.d.ts +29 -0
  122. package/dist/state/useChatSession.d.ts.map +1 -0
  123. package/dist/state/useChatSession.js +37 -0
  124. package/dist/state/useChatSession.js.map +1 -0
  125. package/dist/state/useScrollLock.d.ts +35 -0
  126. package/dist/state/useScrollLock.d.ts.map +1 -0
  127. package/dist/state/useScrollLock.js +74 -0
  128. package/dist/state/useScrollLock.js.map +1 -0
  129. package/dist/state/useToolCallStream.d.ts +38 -0
  130. package/dist/state/useToolCallStream.d.ts.map +1 -0
  131. package/dist/state/useToolCallStream.js +74 -0
  132. package/dist/state/useToolCallStream.js.map +1 -0
  133. package/package.json +10 -2
  134. package/src/cards/ActionSegmentCard.tsx +60 -0
  135. package/src/cards/AnalysisDocumentCard.tsx +81 -0
  136. package/src/cards/AskUserQuestionsCard.tsx +354 -0
  137. package/src/cards/CoverageReportCard.tsx +215 -0
  138. package/src/cards/OrchestrationPlanCard.tsx +328 -0
  139. package/src/cards/PlanSelectorCard.tsx +312 -0
  140. package/src/cards/PlanStepsCard.tsx +52 -0
  141. package/src/cards/QuizCard.tsx +309 -0
  142. package/src/cards/ResearchPlanCard.tsx +169 -0
  143. package/src/cards/SearchSourcesCard.tsx +244 -0
  144. package/src/cards/SuggestedRepliesCard.tsx +48 -0
  145. package/src/cards/SummaryMessageCard.tsx +145 -0
  146. package/src/cards/ThinkingProcessCard.tsx +155 -0
  147. package/src/cards/ToolExecutionCard.tsx +152 -0
  148. package/src/cards/cards.test.tsx +492 -0
  149. package/src/cards/icons.tsx +249 -0
  150. package/src/cards/index.ts +53 -0
  151. package/src/cards/types.ts +27 -0
  152. package/src/components/AgentSelector.stories.tsx +113 -0
  153. package/src/components/AgentSelector.test.tsx +70 -0
  154. package/src/components/AgentSelector.tsx +147 -0
  155. package/src/components/ChatPanel.test.tsx +130 -0
  156. package/src/components/ChatPanel.tsx +612 -138
  157. package/src/components/index.ts +13 -1
  158. package/src/index.ts +43 -0
  159. package/src/state/ChatSessionProvider.test.tsx +85 -0
  160. package/src/state/ChatSessionProvider.tsx +98 -0
  161. package/src/state/MockChatStreamTransport.stories.tsx +95 -0
  162. package/src/state/MockChatStreamTransport.test.ts +163 -0
  163. package/src/state/MockChatStreamTransport.ts +123 -0
  164. package/src/state/bridgeLegacySSE.test.ts +134 -0
  165. package/src/state/bridgeLegacySSE.ts +134 -0
  166. package/src/state/index.ts +82 -0
  167. package/src/state/parseSSE.test.ts +123 -0
  168. package/src/state/parseSSE.ts +123 -0
  169. package/src/state/useChatComposer.test.ts +145 -0
  170. package/src/state/useChatComposer.ts +117 -0
  171. package/src/state/useChatList.test.ts +135 -0
  172. package/src/state/useChatList.ts +217 -0
  173. package/src/state/useChatSession.test.ts +61 -0
  174. package/src/state/useChatSession.ts +64 -0
  175. package/src/state/useScrollLock.test.ts +99 -0
  176. package/src/state/useScrollLock.ts +105 -0
  177. package/src/state/useToolCallStream.test.ts +86 -0
  178. package/src/state/useToolCallStream.ts +110 -0
@@ -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 { ChatPanelProps } from './ChatPanel.js';
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 {
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
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `MockChatStreamTransport` — canned-script transport for stories, tests, and
3
+ * the framework's runnable web-shell example.
4
+ *
5
+ * Replays a deterministic list of `SSEEvent` per call to `stream()`. Optional
6
+ * per-event delays make streaming feel real in demos. Loop the same script
7
+ * forever, or vary by index — the constructor accepts either an array (one
8
+ * script per turn) or a function `(turn: number) => SSEEvent[]`.
9
+ *
10
+ * const transport = new MockChatStreamTransport({
11
+ * scripts: [
12
+ * [{ type: 'content', content: 'Hello, ' }, { type: 'content', content: 'world!' }, { type: 'done' }],
13
+ * ],
14
+ * defaultDelayMs: 25,
15
+ * });
16
+ *
17
+ * const { messages, sendUserMessage } = useChatStream({ transport });
18
+ */
19
+
20
+ import type { SSEEvent } from '@steerable/agent-protocol';
21
+ import type {
22
+ ChatStreamSendInput,
23
+ ChatStreamTransport,
24
+ } from '../hooks/useChatStream.js';
25
+
26
+ export type MockScriptStep =
27
+ | SSEEvent
28
+ | { event: SSEEvent; delayMs?: number };
29
+
30
+ export type MockScript = MockScriptStep[];
31
+
32
+ export interface MockChatStreamTransportOptions {
33
+ /**
34
+ * A list of scripts (one per turn), or a callback that returns a script for
35
+ * the current turn. Required.
36
+ */
37
+ scripts: MockScript[] | ((turn: number, input: ChatStreamSendInput) => MockScript);
38
+ /** Delay between events when the step doesn't specify one. Defaults to 0. */
39
+ defaultDelayMs?: number;
40
+ /**
41
+ * What to do when there are fewer scripts than turns:
42
+ * - 'cycle' (default): wrap modulo `scripts.length`.
43
+ * - 'last': stay on the last script.
44
+ * - 'empty': emit only a `done` event.
45
+ */
46
+ exhaustionPolicy?: 'cycle' | 'last' | 'empty';
47
+ }
48
+
49
+ export class MockChatStreamTransport implements ChatStreamTransport {
50
+ private turn = 0;
51
+ /** Set of in-flight cancel signals, exposed for tests. */
52
+ readonly cancelled = new Set<number>();
53
+
54
+ constructor(private readonly options: MockChatStreamTransportOptions) {}
55
+
56
+ /** Reset the turn counter — useful between fixture-driven test cases. */
57
+ reset(): void {
58
+ this.turn = 0;
59
+ this.cancelled.clear();
60
+ }
61
+
62
+ async stream(
63
+ input: ChatStreamSendInput,
64
+ onEvent: (event: SSEEvent) => void,
65
+ ): Promise<() => void> {
66
+ const turn = this.turn++;
67
+ const script = this.resolveScript(turn, input);
68
+ let cancelled = false;
69
+ const cancel = () => {
70
+ cancelled = true;
71
+ this.cancelled.add(turn);
72
+ };
73
+ void this.run(script, onEvent, () => cancelled);
74
+ // Return synchronously so the framework hook can capture the cancel handle
75
+ // without waiting for the full script.
76
+ return cancel;
77
+ }
78
+
79
+ private resolveScript(turn: number, input: ChatStreamSendInput): MockScript {
80
+ const { scripts, exhaustionPolicy = 'cycle' } = this.options;
81
+ if (typeof scripts === 'function') return scripts(turn, input);
82
+ if (scripts.length === 0) return [{ type: 'done' }];
83
+ if (turn < scripts.length) return scripts[turn];
84
+ switch (exhaustionPolicy) {
85
+ case 'last':
86
+ return scripts[scripts.length - 1];
87
+ case 'empty':
88
+ return [{ type: 'done' }];
89
+ case 'cycle':
90
+ default:
91
+ return scripts[turn % scripts.length];
92
+ }
93
+ }
94
+
95
+ private async run(
96
+ script: MockScript,
97
+ onEvent: (event: SSEEvent) => void,
98
+ isCancelled: () => boolean,
99
+ ): Promise<void> {
100
+ const defaultDelay = this.options.defaultDelayMs ?? 0;
101
+ for (const step of script) {
102
+ if (isCancelled()) return;
103
+ const { event, delayMs } = normaliseStep(step, defaultDelay);
104
+ if (delayMs > 0) await sleep(delayMs);
105
+ if (isCancelled()) return;
106
+ onEvent(event);
107
+ }
108
+ }
109
+ }
110
+
111
+ function normaliseStep(
112
+ step: MockScriptStep,
113
+ defaultDelayMs: number,
114
+ ): { event: SSEEvent; delayMs: number } {
115
+ if ('event' in step && step.event && typeof step.event === 'object') {
116
+ return { event: step.event, delayMs: step.delayMs ?? defaultDelayMs };
117
+ }
118
+ return { event: step as SSEEvent, delayMs: defaultDelayMs };
119
+ }
120
+
121
+ function sleep(ms: number): Promise<void> {
122
+ return new Promise((resolve) => setTimeout(resolve, ms));
123
+ }