@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.
- package/PLAN-messages.md +681 -0
- package/PLAN.md +47 -0
- package/README.md +25 -0
- package/bun.lock +1024 -0
- package/dev-docs/ARCHITECTURE.md +178 -0
- package/dev-docs/CODE_ORGANIZATION.md +232 -0
- package/dev-docs/STANDARDS.md +235 -0
- package/dev-docs/UI_DESIGN.md +425 -0
- package/eslint.config.cjs +194 -0
- package/images/nui.png +0 -0
- package/llxprt.png +0 -0
- package/llxprt.svg +128 -0
- package/package.json +66 -0
- package/scripts/check-limits.ts +177 -0
- package/scripts/start.js +71 -0
- package/src/app.tsx +599 -0
- package/src/bootstrap.tsx +23 -0
- package/src/commands/AuthCommand.tsx +80 -0
- package/src/commands/ModelCommand.tsx +102 -0
- package/src/commands/ProviderCommand.tsx +103 -0
- package/src/commands/ThemeCommand.tsx +71 -0
- package/src/features/chat/history.ts +178 -0
- package/src/features/chat/index.ts +3 -0
- package/src/features/chat/persistentHistory.ts +102 -0
- package/src/features/chat/responder.ts +217 -0
- package/src/features/completion/completions.ts +161 -0
- package/src/features/completion/index.ts +3 -0
- package/src/features/completion/slash.test.ts +82 -0
- package/src/features/completion/slash.ts +248 -0
- package/src/features/completion/suggestions.test.ts +51 -0
- package/src/features/completion/suggestions.ts +112 -0
- package/src/features/config/configSession.test.ts +189 -0
- package/src/features/config/configSession.ts +179 -0
- package/src/features/config/index.ts +4 -0
- package/src/features/config/llxprtAdapter.integration.test.ts +202 -0
- package/src/features/config/llxprtAdapter.test.ts +139 -0
- package/src/features/config/llxprtAdapter.ts +257 -0
- package/src/features/config/llxprtCommands.test.ts +40 -0
- package/src/features/config/llxprtCommands.ts +35 -0
- package/src/features/config/llxprtConfig.test.ts +261 -0
- package/src/features/config/llxprtConfig.ts +418 -0
- package/src/features/theme/index.ts +2 -0
- package/src/features/theme/theme.test.ts +51 -0
- package/src/features/theme/theme.ts +105 -0
- package/src/features/theme/themeManager.ts +84 -0
- package/src/hooks/useAppCommands.ts +129 -0
- package/src/hooks/useApprovalKeyboard.ts +156 -0
- package/src/hooks/useChatStore.test.ts +112 -0
- package/src/hooks/useChatStore.ts +252 -0
- package/src/hooks/useInputManager.ts +99 -0
- package/src/hooks/useKeyboardHandlers.ts +130 -0
- package/src/hooks/useListNavigation.test.ts +166 -0
- package/src/hooks/useListNavigation.ts +62 -0
- package/src/hooks/usePersistentHistory.ts +94 -0
- package/src/hooks/useScrollManagement.ts +107 -0
- package/src/hooks/useSelectionClipboard.ts +48 -0
- package/src/hooks/useSessionManager.test.ts +85 -0
- package/src/hooks/useSessionManager.ts +101 -0
- package/src/hooks/useStreamingLifecycle.ts +71 -0
- package/src/hooks/useStreamingResponder.ts +401 -0
- package/src/hooks/useSuggestionSetup.ts +23 -0
- package/src/hooks/useToolApproval.test.ts +140 -0
- package/src/hooks/useToolApproval.ts +264 -0
- package/src/hooks/useToolScheduler.ts +432 -0
- package/src/index.ts +3 -0
- package/src/jsx.d.ts +11 -0
- package/src/lib/clipboard.ts +18 -0
- package/src/lib/logger.ts +107 -0
- package/src/lib/random.ts +5 -0
- package/src/main.tsx +13 -0
- package/src/test/mockTheme.ts +51 -0
- package/src/types/events.ts +87 -0
- package/src/types.ts +13 -0
- package/src/ui/components/ChatLayout.tsx +694 -0
- package/src/ui/components/CommandComponents.tsx +74 -0
- package/src/ui/components/DiffViewer.tsx +306 -0
- package/src/ui/components/FilterInput.test.ts +69 -0
- package/src/ui/components/FilterInput.tsx +62 -0
- package/src/ui/components/HeaderBar.tsx +137 -0
- package/src/ui/components/RadioSelect.test.ts +140 -0
- package/src/ui/components/RadioSelect.tsx +88 -0
- package/src/ui/components/SelectableList.test.ts +83 -0
- package/src/ui/components/SelectableList.tsx +35 -0
- package/src/ui/components/StatusBar.tsx +45 -0
- package/src/ui/components/SuggestionPanel.tsx +102 -0
- package/src/ui/components/messages/ModelMessage.tsx +14 -0
- package/src/ui/components/messages/SystemMessage.tsx +29 -0
- package/src/ui/components/messages/ThinkingMessage.tsx +14 -0
- package/src/ui/components/messages/UserMessage.tsx +26 -0
- package/src/ui/components/messages/index.ts +15 -0
- package/src/ui/components/messages/renderMessage.test.ts +49 -0
- package/src/ui/components/messages/renderMessage.tsx +43 -0
- package/src/ui/components/messages/types.test.ts +24 -0
- package/src/ui/components/messages/types.ts +36 -0
- package/src/ui/modals/AuthModal.tsx +106 -0
- package/src/ui/modals/ModalShell.tsx +60 -0
- package/src/ui/modals/SearchSelectModal.tsx +236 -0
- package/src/ui/modals/ThemeModal.tsx +204 -0
- package/src/ui/modals/ToolApprovalModal.test.ts +206 -0
- package/src/ui/modals/ToolApprovalModal.tsx +282 -0
- package/src/ui/modals/index.ts +20 -0
- package/src/ui/modals/modals.test.ts +26 -0
- package/src/ui/modals/types.ts +19 -0
- package/src/uicontext/Command.tsx +102 -0
- package/src/uicontext/Dialog.tsx +65 -0
- package/src/uicontext/index.ts +2 -0
- package/themes/ansi-light.json +59 -0
- package/themes/ansi.json +59 -0
- package/themes/atom-one-dark.json +59 -0
- package/themes/ayu-light.json +59 -0
- package/themes/ayu.json +59 -0
- package/themes/default-light.json +59 -0
- package/themes/default.json +59 -0
- package/themes/dracula.json +59 -0
- package/themes/github-dark.json +59 -0
- package/themes/github-light.json +59 -0
- package/themes/googlecode.json +59 -0
- package/themes/green-screen.json +59 -0
- package/themes/no-color.json +59 -0
- package/themes/shades-of-purple.json +59 -0
- package/themes/xcode.json +59 -0
- package/tsconfig.json +28 -0
- 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,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
|
+
});
|