@probelabs/probe-chat
Version:
CLI and web interface for Probe code search (formerly @probelabs/probe-web and @probelabs/probe-chat)
530 lines (459 loc) • 18.7 kB
JavaScript
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { ProbeChat } from '../probeChat.js';
import { MockLLMProvider, createMockStreamText } from './mocks/mockLLMProvider.js';
import MockBackend from '../implement/backends/MockBackend.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Test environment setup
export function setupTestEnvironment() {
// Store original environment
const originalEnv = { ...process.env };
// Clear API keys to ensure mock providers are used
delete process.env.ANTHROPIC_API_KEY;
delete process.env.OPENAI_API_KEY;
delete process.env.GOOGLE_API_KEY;
// Set test mode
process.env.NODE_ENV = 'test';
process.env.PROBE_TEST_MODE = 'true';
return {
restore: () => {
Object.keys(process.env).forEach(key => {
if (!(key in originalEnv)) {
delete process.env[key];
}
});
Object.assign(process.env, originalEnv);
}
};
}
// Create a test instance of ProbeChat with mocks
export async function createTestProbeChat(options = {}) {
const mockProvider = options.mockProvider instanceof MockLLMProvider ?
options.mockProvider :
new MockLLMProvider({
responses: options.responses || [],
...(typeof options.mockProvider === 'object' ? options.mockProvider : {})
});
const mockStreamText = createMockStreamText(mockProvider);
// Create instance with mocks injected
const probeChat = new ProbeChat({
modelName: 'mock-model',
provider: 'mock',
...options.chatOptions
});
// Set temperature and maxTokens if provided
if (options.chatOptions?.temperature !== undefined) {
probeChat.temperature = options.chatOptions.temperature;
}
if (options.chatOptions?.maxTokens !== undefined) {
probeChat.maxTokens = options.chatOptions.maxTokens;
}
if (options.chatOptions?.systemPrompt !== undefined) {
probeChat.customPrompt = options.chatOptions.systemPrompt;
}
// Override the streamText function
probeChat.streamText = mockStreamText;
// Override model initialization to use our mock
probeChat.initializeModel = function() {
this.model = mockProvider.createMockModel();
this.modelId = 'mock-model';
this.modelInfo = {
provider: 'mock',
contextWindow: 100000,
maxOutput: 4096,
supportsImages: true,
supportsPromptCaching: false
};
this.isInitialized = true;
this.noApiKeysMode = false; // Disable API key mode for tests
this.apiType = 'mock';
};
// Initialize the mock model
probeChat.initializeModel();
// Create mock tool instances
const mockTools = {
probe_search: {
execute: async (args) => {
console.log('Mock probe_search called with:', args);
return createMockProbeResults();
},
validate: (args) => {
if (!args.query) {
throw new Error('Missing required field: query');
}
}
},
probe_query: {
execute: async (args) => {
console.log('Mock probe_query called with:', args);
return {
matches: [{
file: 'test.js',
matches: [{ text: 'mock match' }]
}]
};
}
},
probe_extract: {
execute: async (args) => {
console.log('Mock probe_extract called with:', args);
return {
content: 'mock extracted content',
language: 'javascript',
start_line: 1,
end_line: 10
};
}
},
implement: {
execute: async (args, options) => {
console.log('Mock implement called with:', args);
return {
sessionId: 'mock-session',
status: 'success',
filesModified: ['mock.js'],
summary: 'Mock implementation complete'
};
},
handleError: (error) => {
console.error('Mock implement error:', error);
}
}
};
// Make tools accessible for test overrides
probeChat.tools = mockTools;
// Store mock provider reference for test access
probeChat.mockProvider = mockProvider;
// Add missing ProbeChat methods for tests
probeChat.messages = [];
probeChat.history = probeChat.messages; // Alias for compatibility with real implementation
probeChat.sendMessage = async function(message) {
return await this.chat(message);
};
probeChat.chat = async function(message, options = {}) {
let userMessage;
if (typeof message === 'string') {
userMessage = { role: 'user', content: message };
} else if (message.role && message.content) {
userMessage = message;
} else {
userMessage = { role: 'user', content: message.content || message };
}
this.messages.push(userMessage);
// Call the mock provider with streaming support
const response = await mockStreamText({
model: this.model,
messages: this.messages,
system: this.customPrompt || 'You are a helpful assistant.',
temperature: this.temperature,
maxTokens: this.maxTokens
});
let fullResponse = '';
// Stream chunks if callback provided
if (options.onStream) {
for await (const chunk of response.textStream) {
options.onStream(chunk);
fullResponse += chunk;
}
} else {
// Otherwise just collect the full response
for await (const chunk of response.textStream) {
fullResponse += chunk;
}
}
// Check if the mock provider returned tool calls and convert them to XML format
const currentResponseIndex = this.mockProvider.currentResponseIndex - 1;
const mockResponse = this.mockProvider.responses[currentResponseIndex];
if (mockResponse?.toolCalls) {
// Convert AI SDK tool calls to XML format that ProbeChat expects
for (const toolCall of mockResponse.toolCalls) {
const xmlToolCall = this.convertToolCallToXml(toolCall);
fullResponse += '\n\n' + xmlToolCall;
}
}
const assistantMessage = { role: 'assistant', content: fullResponse };
this.messages.push(assistantMessage);
return fullResponse;
};
// Helper to convert AI SDK tool calls to XML format
probeChat.convertToolCallToXml = function(toolCall) {
const { toolName, args } = toolCall;
let xmlContent = `<${toolName}>`;
if (args) {
for (const [key, value] of Object.entries(args)) {
xmlContent += `\n<${key}>${value}</${key}>`;
}
}
xmlContent += `\n</${toolName}>`;
return xmlContent;
};
probeChat.resetConversation = function() {
this.messages = [];
};
probeChat.clearHistory = function() {
this.messages = [];
this.history = this.messages; // Alias for compatibility
return 'mock-session-id'; // Return a mock session ID like the real implementation
};
probeChat.handleToolError = function(error, toolName) {
console.error(`Tool error in ${toolName}:`, error);
return `Error executing ${toolName}: ${error.message}`;
};
probeChat.processImageUrl = async function(url) {
return 'mock-base64-image-data';
};
// Register mock backend if implement tool is being tested
if (options.useMockBackend) {
const mockBackend = new MockBackend();
if (options.mockBackendResponses) {
mockBackend.setResponses(options.mockBackendResponses);
}
// Override the implement tool's execute method to use our mock backend
const originalImplementExecute = probeChat.tools.implement.execute;
probeChat.tools.implement.execute = async function(args, options) {
// Force the mock backend
args.backend = 'mock';
// If we need to register the backend, do it here
if (this.backendManager) {
await this.backendManager.registerBackend(mockBackend);
} else {
// Direct execution with mock backend
return await mockBackend.implement(args, options);
}
return originalImplementExecute ? originalImplementExecute.call(this, args, options) :
await mockBackend.implement(args, options);
};
// Return both for test access
return { probeChat, mockProvider, mockBackend };
}
return { probeChat, mockProvider };
}
// Helper to capture console output
export function captureConsoleOutput() {
const originalLog = console.log;
const originalError = console.error;
const output = [];
const errors = [];
console.log = (...args) => {
output.push(args.join(' '));
};
console.error = (...args) => {
errors.push(args.join(' '));
};
return {
output,
errors,
restore: () => {
console.log = originalLog;
console.error = originalError;
},
getOutput: () => output.join('\n'),
getErrors: () => errors.join('\n')
};
}
// Helper to create temporary test files
export function createTempTestFiles(files) {
const tempDir = path.join(__dirname, 'temp-test-' + Date.now());
fs.mkdirSync(tempDir, { recursive: true });
const createdFiles = [];
for (const [filePath, content] of Object.entries(files)) {
const fullPath = path.join(tempDir, filePath);
const dir = path.dirname(fullPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(fullPath, content);
createdFiles.push(fullPath);
}
return {
tempDir,
files: createdFiles,
cleanup: () => {
fs.rmSync(tempDir, { recursive: true, force: true });
}
};
}
// Helper to run a chat interaction and capture results
export async function runChatInteraction(probeChat, messages, options = {}) {
const results = {
responses: [],
toolCalls: [],
errors: [],
streamedText: ''
};
// Override chat to handle tool calls from mock responses
const originalChat = probeChat.chat.bind(probeChat);
probeChat.chat = async function(message, opts = {}) {
const response = await originalChat(message, {
...opts,
onStream: (text) => {
results.streamedText += text;
if (opts.onStream) opts.onStream(text);
}
});
// Check if the mock provider returned tool calls and execute them
const lastCall = probeChat.mockProvider?.capturedCalls[probeChat.mockProvider.capturedCalls.length - 1];
if (lastCall) {
const mockResponse = probeChat.mockProvider.responses[probeChat.mockProvider.currentResponseIndex - 1];
if (mockResponse?.toolCalls) {
for (const toolCall of mockResponse.toolCalls) {
results.toolCalls.push(toolCall);
// Actually execute the tool to trigger the test's tracking
const toolName = toolCall.toolName;
if (probeChat.tools && probeChat.tools[toolName]) {
try {
// Validate tool arguments if validate method exists
if (typeof probeChat.tools[toolName].validate === 'function') {
probeChat.tools[toolName].validate(toolCall.args);
}
await probeChat.tools[toolName].execute(toolCall.args);
} catch (error) {
// Tool execution errors are expected in some tests
console.warn(`Tool execution error for ${toolName}:`, error.message);
// Call the global error handler if it exists (for testing)
if (typeof probeChat.handleToolError === 'function') {
probeChat.handleToolError(error, toolName);
}
// Call the tool-specific error handler if it exists (for testing)
if (typeof probeChat.tools[toolName].handleError === 'function') {
probeChat.tools[toolName].handleError(error);
}
}
}
if (opts.onToolCall) opts.onToolCall(toolCall);
}
}
}
return response;
};
// Store reference to mock provider in probeChat for access
if (probeChat.model?.mockProvider) {
probeChat.mockProvider = probeChat.model.mockProvider;
}
for (const message of messages) {
try {
let currentResponse = await probeChat.chat(message, options);
results.responses.push(currentResponse);
// Continue the conversation if there are more responses with tool calls
// This simulates multi-turn tool calling conversations
let maxTurns = 10; // Prevent infinite loops
while (maxTurns > 0 && probeChat.mockProvider &&
probeChat.mockProvider.currentResponseIndex < probeChat.mockProvider.responses.length) {
const nextResponseIndex = probeChat.mockProvider.currentResponseIndex;
const nextResponse = probeChat.mockProvider.responses[nextResponseIndex];
// If the next response has tool calls, continue the conversation
if (nextResponse?.toolCalls) {
// Add a synthetic assistant message to continue the flow
const continuationResponse = await probeChat.chat({
role: 'assistant',
content: nextResponse.text || 'Continuing with next tool...'
}, options);
results.responses.push(continuationResponse);
} else {
// No more tool calls, conversation ends
break;
}
maxTurns--;
}
} catch (error) {
results.errors.push(error);
}
}
// Restore original chat
probeChat.chat = originalChat;
return results;
}
// Helper to create test probe search results
export function createMockProbeResults(options = {}) {
const defaultResult = {
path: options.path || 'test/file.js',
matches: options.matches || [{
line: 1,
column: 0,
match: options.match || 'test match',
context: options.context || 'function test() { return "test match"; }'
}],
score: options.score || 0.95,
excerpt: options.excerpt || 'function test() { return "test match"; }',
symbols: options.symbols || [{
name: 'test',
kind: 'function',
line: 1
}]
};
return {
results: options.results || [defaultResult],
total_matches: options.total_matches || 1,
search_time_ms: options.search_time_ms || 50,
bytes_processed: options.bytes_processed || 1000,
files_searched: options.files_searched || 10
};
}
// Helper to wait for async operations
export function waitFor(condition, timeout = 5000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const interval = setInterval(() => {
if (condition()) {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
reject(new Error('Timeout waiting for condition'));
}
}, 100);
});
}
// Test assertion helpers
export const assert = {
includesText: (actual, expected, message) => {
if (!actual.includes(expected)) {
throw new Error(message || `Expected "${actual}" to include "${expected}"`);
}
},
toolCallMade: (toolCalls, toolName, message) => {
const found = toolCalls.some(call => call.toolName === toolName);
if (!found) {
throw new Error(message || `Expected tool call "${toolName}" to be made`);
}
},
noErrors: (errors, message) => {
if (errors.length > 0) {
throw new Error(message || `Expected no errors but got: ${errors.map(e => e.message).join(', ')}`);
}
},
responseCount: (responses, expected, message) => {
if (responses.length !== expected) {
throw new Error(message || `Expected ${expected} responses but got ${responses.length}`);
}
}
};
// Export test data
export const testData = {
sampleCode: {
javascript: `function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}`,
python: `def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)`,
rust: `fn fibonacci(n: u32) -> u32 {
match n {
0 | 1 => n,
_ => fibonacci(n - 1) + fibonacci(n - 2)
}
}`
},
sampleQueries: {
simple: 'function fibonacci',
complex: 'function AND (recursive OR recursion)',
withPath: { query: 'fibonacci', path: './src' },
withOptions: {
query: 'fibonacci',
maxResults: 5,
maxTokens: 1000
}
}
};