@vybestack/llxprt-ui 0.7.0-nightly.251211.5750c518a

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 (123) hide show
  1. package/PLAN-messages.md +681 -0
  2. package/PLAN.md +47 -0
  3. package/README.md +25 -0
  4. package/bun.lock +1024 -0
  5. package/dev-docs/ARCHITECTURE.md +178 -0
  6. package/dev-docs/CODE_ORGANIZATION.md +232 -0
  7. package/dev-docs/STANDARDS.md +235 -0
  8. package/dev-docs/UI_DESIGN.md +425 -0
  9. package/eslint.config.cjs +194 -0
  10. package/images/nui.png +0 -0
  11. package/llxprt.png +0 -0
  12. package/llxprt.svg +128 -0
  13. package/package.json +66 -0
  14. package/scripts/check-limits.ts +177 -0
  15. package/scripts/start.js +71 -0
  16. package/src/app.tsx +599 -0
  17. package/src/bootstrap.tsx +23 -0
  18. package/src/commands/AuthCommand.tsx +80 -0
  19. package/src/commands/ModelCommand.tsx +102 -0
  20. package/src/commands/ProviderCommand.tsx +103 -0
  21. package/src/commands/ThemeCommand.tsx +71 -0
  22. package/src/features/chat/history.ts +178 -0
  23. package/src/features/chat/index.ts +3 -0
  24. package/src/features/chat/persistentHistory.ts +102 -0
  25. package/src/features/chat/responder.ts +217 -0
  26. package/src/features/completion/completions.ts +161 -0
  27. package/src/features/completion/index.ts +3 -0
  28. package/src/features/completion/slash.test.ts +82 -0
  29. package/src/features/completion/slash.ts +248 -0
  30. package/src/features/completion/suggestions.test.ts +51 -0
  31. package/src/features/completion/suggestions.ts +112 -0
  32. package/src/features/config/configSession.test.ts +189 -0
  33. package/src/features/config/configSession.ts +179 -0
  34. package/src/features/config/index.ts +4 -0
  35. package/src/features/config/llxprtAdapter.integration.test.ts +202 -0
  36. package/src/features/config/llxprtAdapter.test.ts +139 -0
  37. package/src/features/config/llxprtAdapter.ts +257 -0
  38. package/src/features/config/llxprtCommands.test.ts +40 -0
  39. package/src/features/config/llxprtCommands.ts +35 -0
  40. package/src/features/config/llxprtConfig.test.ts +261 -0
  41. package/src/features/config/llxprtConfig.ts +418 -0
  42. package/src/features/theme/index.ts +2 -0
  43. package/src/features/theme/theme.test.ts +51 -0
  44. package/src/features/theme/theme.ts +105 -0
  45. package/src/features/theme/themeManager.ts +84 -0
  46. package/src/hooks/useAppCommands.ts +129 -0
  47. package/src/hooks/useApprovalKeyboard.ts +156 -0
  48. package/src/hooks/useChatStore.test.ts +112 -0
  49. package/src/hooks/useChatStore.ts +252 -0
  50. package/src/hooks/useInputManager.ts +99 -0
  51. package/src/hooks/useKeyboardHandlers.ts +130 -0
  52. package/src/hooks/useListNavigation.test.ts +166 -0
  53. package/src/hooks/useListNavigation.ts +62 -0
  54. package/src/hooks/usePersistentHistory.ts +94 -0
  55. package/src/hooks/useScrollManagement.ts +107 -0
  56. package/src/hooks/useSelectionClipboard.ts +48 -0
  57. package/src/hooks/useSessionManager.test.ts +85 -0
  58. package/src/hooks/useSessionManager.ts +101 -0
  59. package/src/hooks/useStreamingLifecycle.ts +71 -0
  60. package/src/hooks/useStreamingResponder.ts +401 -0
  61. package/src/hooks/useSuggestionSetup.ts +23 -0
  62. package/src/hooks/useToolApproval.test.ts +140 -0
  63. package/src/hooks/useToolApproval.ts +264 -0
  64. package/src/hooks/useToolScheduler.ts +432 -0
  65. package/src/index.ts +3 -0
  66. package/src/jsx.d.ts +11 -0
  67. package/src/lib/clipboard.ts +18 -0
  68. package/src/lib/logger.ts +107 -0
  69. package/src/lib/random.ts +5 -0
  70. package/src/main.tsx +13 -0
  71. package/src/test/mockTheme.ts +51 -0
  72. package/src/types/events.ts +87 -0
  73. package/src/types.ts +13 -0
  74. package/src/ui/components/ChatLayout.tsx +694 -0
  75. package/src/ui/components/CommandComponents.tsx +74 -0
  76. package/src/ui/components/DiffViewer.tsx +306 -0
  77. package/src/ui/components/FilterInput.test.ts +69 -0
  78. package/src/ui/components/FilterInput.tsx +62 -0
  79. package/src/ui/components/HeaderBar.tsx +137 -0
  80. package/src/ui/components/RadioSelect.test.ts +140 -0
  81. package/src/ui/components/RadioSelect.tsx +88 -0
  82. package/src/ui/components/SelectableList.test.ts +83 -0
  83. package/src/ui/components/SelectableList.tsx +35 -0
  84. package/src/ui/components/StatusBar.tsx +45 -0
  85. package/src/ui/components/SuggestionPanel.tsx +102 -0
  86. package/src/ui/components/messages/ModelMessage.tsx +14 -0
  87. package/src/ui/components/messages/SystemMessage.tsx +29 -0
  88. package/src/ui/components/messages/ThinkingMessage.tsx +14 -0
  89. package/src/ui/components/messages/UserMessage.tsx +26 -0
  90. package/src/ui/components/messages/index.ts +15 -0
  91. package/src/ui/components/messages/renderMessage.test.ts +49 -0
  92. package/src/ui/components/messages/renderMessage.tsx +43 -0
  93. package/src/ui/components/messages/types.test.ts +24 -0
  94. package/src/ui/components/messages/types.ts +36 -0
  95. package/src/ui/modals/AuthModal.tsx +106 -0
  96. package/src/ui/modals/ModalShell.tsx +60 -0
  97. package/src/ui/modals/SearchSelectModal.tsx +236 -0
  98. package/src/ui/modals/ThemeModal.tsx +204 -0
  99. package/src/ui/modals/ToolApprovalModal.test.ts +206 -0
  100. package/src/ui/modals/ToolApprovalModal.tsx +282 -0
  101. package/src/ui/modals/index.ts +20 -0
  102. package/src/ui/modals/modals.test.ts +26 -0
  103. package/src/ui/modals/types.ts +19 -0
  104. package/src/uicontext/Command.tsx +102 -0
  105. package/src/uicontext/Dialog.tsx +65 -0
  106. package/src/uicontext/index.ts +2 -0
  107. package/themes/ansi-light.json +59 -0
  108. package/themes/ansi.json +59 -0
  109. package/themes/atom-one-dark.json +59 -0
  110. package/themes/ayu-light.json +59 -0
  111. package/themes/ayu.json +59 -0
  112. package/themes/default-light.json +59 -0
  113. package/themes/default.json +59 -0
  114. package/themes/dracula.json +59 -0
  115. package/themes/github-dark.json +59 -0
  116. package/themes/github-light.json +59 -0
  117. package/themes/googlecode.json +59 -0
  118. package/themes/green-screen.json +59 -0
  119. package/themes/no-color.json +59 -0
  120. package/themes/shades-of-purple.json +59 -0
  121. package/themes/xcode.json +59 -0
  122. package/tsconfig.json +28 -0
  123. package/vitest.config.ts +10 -0
@@ -0,0 +1,179 @@
1
+ import {
2
+ Config,
3
+ AuthType,
4
+ ProviderManager,
5
+ OpenAIProvider,
6
+ AnthropicProvider,
7
+ SettingsService,
8
+ registerSettingsService,
9
+ resetSettingsService,
10
+ } from '@vybestack/llxprt-code-core';
11
+ import type {
12
+ GeminiClient,
13
+ ConfigParameters,
14
+ } from '@vybestack/llxprt-code-core';
15
+
16
+ export interface ConfigSessionOptions {
17
+ readonly model: string;
18
+ readonly workingDir: string;
19
+ readonly provider?: string;
20
+ readonly baseUrl?: string;
21
+ readonly authKeyfile?: string;
22
+ readonly apiKey?: string;
23
+ readonly debugMode?: boolean;
24
+ }
25
+
26
+ export interface ConfigSession {
27
+ readonly config: Config;
28
+ initialize(): Promise<void>;
29
+ getClient(): GeminiClient;
30
+ dispose(): void;
31
+ }
32
+
33
+ /**
34
+ * Create the appropriate provider instance based on provider name.
35
+ */
36
+ function createProvider(
37
+ providerName: string,
38
+ apiKey: string | undefined,
39
+ baseUrl: string | undefined,
40
+ ): OpenAIProvider | AnthropicProvider | null {
41
+ const name = providerName.toLowerCase();
42
+
43
+ if (name === 'openai' || name === 'openai-responses') {
44
+ return new OpenAIProvider(apiKey, baseUrl);
45
+ }
46
+ if (name === 'anthropic') {
47
+ return new AnthropicProvider(apiKey, baseUrl);
48
+ }
49
+
50
+ // Unknown provider - return null and fall back to Gemini
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Check if a provider requires a ProviderManager (non-Gemini providers).
56
+ */
57
+ function requiresProviderManager(providerName: string | undefined): boolean {
58
+ if (!providerName) return false;
59
+ const name = providerName.toLowerCase();
60
+ return (
61
+ name === 'openai' || name === 'openai-responses' || name === 'anthropic'
62
+ );
63
+ }
64
+
65
+ export function createConfigSession(
66
+ options: ConfigSessionOptions,
67
+ ): ConfigSession {
68
+ // Reset any existing context and create a fresh SettingsService
69
+ resetSettingsService();
70
+ const settings = new SettingsService();
71
+ registerSettingsService(settings);
72
+
73
+ // CRITICAL: Set model in SettingsService - providers read from here, not from Config
74
+ settings.set('model', options.model);
75
+
76
+ if (options.baseUrl) {
77
+ settings.set('base-url', options.baseUrl);
78
+ }
79
+ if (options.authKeyfile) {
80
+ settings.set('auth-keyfile', options.authKeyfile);
81
+ }
82
+ if (options.apiKey) {
83
+ settings.set('auth-key', options.apiKey);
84
+ }
85
+ if (options.provider) {
86
+ settings.set('activeProvider', options.provider);
87
+ }
88
+
89
+ const timestamp = Date.now();
90
+ const configParams = {
91
+ sessionId: `nui-${timestamp}`,
92
+ targetDir: options.workingDir,
93
+ cwd: options.workingDir,
94
+ debugMode: options.debugMode ?? false,
95
+ model: options.model,
96
+ provider: options.provider,
97
+ settingsService: settings,
98
+ telemetry: { enabled: false },
99
+ checkpointing: false,
100
+ // Use default llxprt-code policy behavior:
101
+ // - Read-only tools (read_file, glob, etc.) are auto-approved
102
+ // - Write tools (edit, shell, write_file) require confirmation
103
+ // The default TOML policies handle this via priority-based rules
104
+ policyEngineConfig: {
105
+ defaultDecision: 'ask_user' as const,
106
+ },
107
+ } as ConfigParameters;
108
+
109
+ const config = new Config(configParams);
110
+
111
+ let initialized = false;
112
+ let client: GeminiClient | undefined;
113
+
114
+ return {
115
+ config,
116
+
117
+ async initialize(): Promise<void> {
118
+ if (initialized) {
119
+ return;
120
+ }
121
+ await config.initialize();
122
+
123
+ const providerName = options.provider?.toLowerCase();
124
+
125
+ // For non-Gemini providers, set up ProviderManager first
126
+ if (requiresProviderManager(options.provider)) {
127
+ const provider = createProvider(
128
+ options.provider ?? '',
129
+ options.apiKey,
130
+ options.baseUrl,
131
+ );
132
+
133
+ if (provider) {
134
+ const providerManager = new ProviderManager();
135
+ providerManager.setConfig(config);
136
+ providerManager.registerProvider(provider);
137
+ providerManager.setActiveProvider(provider.name);
138
+ config.setProviderManager(providerManager);
139
+ }
140
+ }
141
+
142
+ // Determine auth type based on provider
143
+ let authType: AuthType;
144
+
145
+ if (providerName === 'gemini' || !providerName) {
146
+ // For Gemini: use API key auth if available, otherwise vertex AI for keyfile
147
+ if (options.apiKey) {
148
+ authType = AuthType.USE_GEMINI;
149
+ } else if (options.authKeyfile) {
150
+ authType = AuthType.USE_VERTEX_AI;
151
+ } else {
152
+ authType = AuthType.USE_GEMINI;
153
+ }
154
+ } else {
155
+ // For other providers: use provider-managed auth (requires ProviderManager)
156
+ authType = AuthType.USE_PROVIDER;
157
+ }
158
+
159
+ await config.refreshAuth(authType);
160
+ client = config.getGeminiClient();
161
+ initialized = true;
162
+ },
163
+
164
+ getClient(): GeminiClient {
165
+ if (!client) {
166
+ throw new Error(
167
+ 'ConfigSession not initialized. Call initialize() first.',
168
+ );
169
+ }
170
+ return client;
171
+ },
172
+
173
+ dispose(): void {
174
+ initialized = false;
175
+ client = undefined;
176
+ resetSettingsService();
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,4 @@
1
+ export * from './llxprtConfig';
2
+ export * from './llxprtAdapter';
3
+ export * from './llxprtCommands';
4
+ export * from './configSession';
@@ -0,0 +1,202 @@
1
+ /**
2
+ * @vitest-environment node
3
+ *
4
+ * End-to-end integration tests for the llxprt adapter.
5
+ * These tests actually send messages to real providers and verify responses.
6
+ * They require a valid synthetic profile to be configured.
7
+ */
8
+ import { readFileSync, existsSync } from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { createConfigSession, type ConfigSession } from './configSession';
13
+ import { sendMessageWithSession, type AdapterEvent } from './llxprtAdapter';
14
+
15
+ const SYNTHETIC_PROFILE_PATH = path.join(
16
+ os.homedir(),
17
+ '.llxprt/profiles/synthetic.json',
18
+ );
19
+
20
+ interface ProfileData {
21
+ provider?: string;
22
+ model?: string;
23
+ baseUrl?: string;
24
+ authKeyfile?: string;
25
+ ephemeralSettings?: Record<string, unknown>;
26
+ }
27
+
28
+ function loadSyntheticProfile(): ProfileData | null {
29
+ if (!existsSync(SYNTHETIC_PROFILE_PATH)) {
30
+ return null;
31
+ }
32
+ try {
33
+ return JSON.parse(
34
+ readFileSync(SYNTHETIC_PROFILE_PATH, 'utf8'),
35
+ ) as ProfileData;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function readApiKey(keyfilePath: string): string | undefined {
42
+ try {
43
+ return readFileSync(keyfilePath, 'utf8').trim();
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ function createSessionFromProfile(profile: ProfileData): ConfigSession | null {
50
+ const ephemeral = profile.ephemeralSettings ?? {};
51
+ const provider = profile.provider;
52
+ const baseUrl = (ephemeral['base-url'] ??
53
+ ephemeral.baseUrl ??
54
+ profile.baseUrl) as string | undefined;
55
+ const keyFilePath = (ephemeral['auth-keyfile'] ??
56
+ ephemeral.authKeyfile ??
57
+ profile.authKeyfile) as string | undefined;
58
+ const model = (ephemeral.model ?? profile.model) as string | undefined;
59
+
60
+ if (!provider || !baseUrl || !model) {
61
+ return null;
62
+ }
63
+
64
+ // Read the API key from keyfile if provided
65
+ const apiKey = keyFilePath ? readApiKey(keyFilePath) : undefined;
66
+ if (keyFilePath && !apiKey) {
67
+ return null; // Keyfile specified but couldn't read it
68
+ }
69
+
70
+ return createConfigSession({
71
+ model,
72
+ provider,
73
+ baseUrl,
74
+ apiKey,
75
+ workingDir: process.cwd(),
76
+ });
77
+ }
78
+
79
+ const syntheticProfile = loadSyntheticProfile();
80
+ const describeSynthetic = syntheticProfile ? describe : describe.skip;
81
+
82
+ describe('llxprtAdapter end-to-end', () => {
83
+ describeSynthetic('with synthetic profile', () => {
84
+ /**
85
+ * This is the CRITICAL end-to-end test that verifies the complete flow:
86
+ * 1. Create ConfigSession from profile
87
+ * 2. Initialize session (sets up auth, ProviderManager if needed)
88
+ * 3. Send a message via sendMessageWithSession
89
+ * 4. Receive streaming events
90
+ * 5. Verify we get actual text content
91
+ *
92
+ * This test would catch:
93
+ * - AuthType configuration errors
94
+ * - ProviderManager setup issues
95
+ * - Authorization header format issues
96
+ * - API key reading issues
97
+ * - Any HTTP/streaming issues
98
+ */
99
+ it('sends a message and receives streaming response', async () => {
100
+ const session = createSessionFromProfile(syntheticProfile!);
101
+ expect(session).not.toBeNull();
102
+
103
+ // Initialize the session - this is where auth errors would surface
104
+ await session!.initialize();
105
+
106
+ const events: AdapterEvent[] = [];
107
+ const controller = new AbortController();
108
+ const timeout = setTimeout(() => controller.abort(), 45000);
109
+
110
+ try {
111
+ for await (const event of sendMessageWithSession(
112
+ session!,
113
+ 'Say exactly: Hello from integration test',
114
+ controller.signal,
115
+ )) {
116
+ events.push(event);
117
+
118
+ // Stop after getting some text to avoid running up API costs
119
+ const textDeltaCount = events.filter(
120
+ (e) => e.type === 'text_delta',
121
+ ).length;
122
+ if (textDeltaCount >= 3) {
123
+ controller.abort();
124
+ break;
125
+ }
126
+ }
127
+ } catch (error) {
128
+ // AbortError is expected when we manually abort
129
+ if (error instanceof Error && error.name !== 'AbortError') {
130
+ throw error;
131
+ }
132
+ } finally {
133
+ clearTimeout(timeout);
134
+ session!.dispose();
135
+ }
136
+
137
+ // Verify we got text events
138
+ const textDeltas = events.filter(
139
+ (e): e is Extract<AdapterEvent, { type: 'text_delta' }> =>
140
+ e.type === 'text_delta',
141
+ );
142
+ expect(textDeltas.length).toBeGreaterThan(0);
143
+
144
+ // Verify the combined text is non-empty
145
+ const combinedText = textDeltas.map((e) => e.text).join('');
146
+ expect(combinedText.length).toBeGreaterThan(0);
147
+ }, 60000);
148
+
149
+ /**
150
+ * Test that session initialization works without sending a message.
151
+ * This catches auth setup issues faster than the full message test.
152
+ */
153
+ it('initializes session successfully', async () => {
154
+ const session = createSessionFromProfile(syntheticProfile!);
155
+ expect(session).not.toBeNull();
156
+
157
+ // This should NOT throw any errors
158
+ await session!.initialize();
159
+
160
+ // Client should be available
161
+ const client = session!.getClient();
162
+ expect(client).toBeDefined();
163
+
164
+ session!.dispose();
165
+ }, 30000);
166
+
167
+ /**
168
+ * Test that tools are registered after initialization.
169
+ */
170
+ it('has tools registered after initialization', async () => {
171
+ const session = createSessionFromProfile(syntheticProfile!);
172
+ expect(session).not.toBeNull();
173
+
174
+ await session!.initialize();
175
+
176
+ const registry = session!.config.getToolRegistry();
177
+ const tools = registry.getFunctionDeclarations();
178
+ expect(tools.length).toBeGreaterThan(0);
179
+
180
+ session!.dispose();
181
+ }, 30000);
182
+
183
+ /**
184
+ * Test that model is correctly propagated through Config.
185
+ */
186
+ it('uses correct model from profile', async () => {
187
+ const session = createSessionFromProfile(syntheticProfile!);
188
+ expect(session).not.toBeNull();
189
+
190
+ // Check model before and after initialization
191
+ const modelBeforeInit = session!.config.getModel();
192
+ expect(modelBeforeInit).toBe(syntheticProfile!.model);
193
+
194
+ await session!.initialize();
195
+
196
+ const modelAfterInit = session!.config.getModel();
197
+ expect(modelAfterInit).toBe(syntheticProfile!.model);
198
+
199
+ session!.dispose();
200
+ }, 30000);
201
+ });
202
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { transformEvent, transformStream } from './llxprtAdapter';
3
+ import {
4
+ GeminiEventType,
5
+ type ServerGeminiContentEvent,
6
+ type ServerGeminiThoughtEvent,
7
+ type ServerGeminiToolCallRequestEvent,
8
+ type ServerGeminiFinishedEvent,
9
+ type ServerGeminiErrorEvent,
10
+ type ServerGeminiStreamEvent,
11
+ } from '@vybestack/llxprt-code-core';
12
+ import { FinishReason } from '@google/genai';
13
+ import type { AdapterEvent } from '../../types/events';
14
+
15
+ describe('transformEvent', () => {
16
+ it('should convert Content event to text_delta', () => {
17
+ const input: ServerGeminiContentEvent = {
18
+ type: GeminiEventType.Content,
19
+ value: 'hello world',
20
+ };
21
+ const result = transformEvent(input);
22
+ expect(result).toStrictEqual({ type: 'text_delta', text: 'hello world' });
23
+ });
24
+
25
+ it('should convert Thought event to thinking_delta', () => {
26
+ const input: ServerGeminiThoughtEvent = {
27
+ type: GeminiEventType.Thought,
28
+ value: { subject: 'analysis', description: 'thinking about the problem' },
29
+ };
30
+ const result = transformEvent(input);
31
+ expect(result).toStrictEqual({
32
+ type: 'thinking_delta',
33
+ text: 'thinking about the problem',
34
+ });
35
+ });
36
+
37
+ it('should convert ToolCallRequest to tool_pending', () => {
38
+ const input: ServerGeminiToolCallRequestEvent = {
39
+ type: GeminiEventType.ToolCallRequest,
40
+ value: {
41
+ callId: 'call_123',
42
+ name: 'read_file',
43
+ args: { path: '/foo/bar.txt' },
44
+ isClientInitiated: false,
45
+ prompt_id: 'prompt_456',
46
+ },
47
+ };
48
+ const result = transformEvent(input);
49
+ expect(result).toStrictEqual({
50
+ type: 'tool_pending',
51
+ id: 'call_123',
52
+ name: 'read_file',
53
+ params: { path: '/foo/bar.txt' },
54
+ });
55
+ });
56
+
57
+ it('should convert Finished to complete', () => {
58
+ const input: ServerGeminiFinishedEvent = {
59
+ type: GeminiEventType.Finished,
60
+ value: { reason: FinishReason.STOP },
61
+ };
62
+ const result = transformEvent(input);
63
+ expect(result).toStrictEqual({ type: 'complete' });
64
+ });
65
+
66
+ it('should convert Error to error event', () => {
67
+ const input: ServerGeminiErrorEvent = {
68
+ type: GeminiEventType.Error,
69
+ value: { error: { message: 'something broke', status: 500 } },
70
+ };
71
+ const result = transformEvent(input);
72
+ expect(result).toStrictEqual({ type: 'error', message: 'something broke' });
73
+ });
74
+
75
+ it('should handle unknown event type with fallback', () => {
76
+ const input = {
77
+ type: 'unknown_type' as GeminiEventType,
78
+ value: { foo: 'bar' },
79
+ } as unknown as ServerGeminiStreamEvent;
80
+ const result = transformEvent(input);
81
+ expect(result.type).toBe('unknown');
82
+ expect(result).toHaveProperty('raw');
83
+ });
84
+ });
85
+
86
+ describe('transformStream', () => {
87
+ it('should yield transformed events from async iterable', async () => {
88
+ const mockEvents: ServerGeminiStreamEvent[] = [
89
+ {
90
+ type: GeminiEventType.Content,
91
+ value: 'Hello',
92
+ } as ServerGeminiStreamEvent,
93
+ {
94
+ type: GeminiEventType.Content,
95
+ value: ' world',
96
+ } as ServerGeminiStreamEvent,
97
+ {
98
+ type: GeminiEventType.Finished,
99
+ value: { reason: FinishReason.STOP },
100
+ } as ServerGeminiStreamEvent,
101
+ ];
102
+
103
+ async function* mockStream(): AsyncGenerator<ServerGeminiStreamEvent> {
104
+ for (const event of mockEvents) {
105
+ yield await Promise.resolve(event);
106
+ }
107
+ }
108
+
109
+ const results: AdapterEvent[] = [];
110
+ for await (const event of transformStream(mockStream())) {
111
+ results.push(event);
112
+ }
113
+
114
+ expect(results).toHaveLength(3);
115
+ expect(results[0]).toStrictEqual({ type: 'text_delta', text: 'Hello' });
116
+ expect(results[1]).toStrictEqual({ type: 'text_delta', text: ' world' });
117
+ expect(results[2]).toStrictEqual({ type: 'complete' });
118
+ });
119
+
120
+ it('should handle empty stream', async () => {
121
+ const createEmptyStream = (
122
+ events: ServerGeminiStreamEvent[],
123
+ ): AsyncGenerator<ServerGeminiStreamEvent> => {
124
+ async function* generator(): AsyncGenerator<ServerGeminiStreamEvent> {
125
+ for (const event of events) {
126
+ yield await Promise.resolve(event);
127
+ }
128
+ }
129
+ return generator();
130
+ };
131
+
132
+ const results: AdapterEvent[] = [];
133
+ for await (const event of transformStream(createEmptyStream([]))) {
134
+ results.push(event);
135
+ }
136
+
137
+ expect(results).toHaveLength(0);
138
+ });
139
+ });