tool messages WIP and add integration test framework

This commit is contained in:
Nemo Godebski-Pedersen 2025-03-25 11:06:42 +00:00
parent 4e689dfaa8
commit 8a660065f7
10 changed files with 339 additions and 23 deletions

45
package-lock.json generated
View File

@ -70,6 +70,7 @@
"jsdom": "^25.0.1",
"node-mocks-http": "^1.16.2",
"npm-run-all2": "^7.0.1",
"playwright": "^1.51.1",
"prettier": "^3.0.1",
"superagent": "^9.0.0",
"supertest": "^7.0.0",
@ -11249,6 +11250,50 @@
"pathe": "^2.0.1"
}
},
"node_modules/playwright": {
"version": "1.51.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz",
"integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==",
"dev": true,
"dependencies": {
"playwright-core": "1.51.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.51.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz",
"integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",

View File

@ -14,7 +14,10 @@
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/ server/"
"format": "prettier --write src/ server/",
"test:integration": "vitest run --config vitest.integration.config.js",
"test:integration:watch": "vitest --config vitest.integration.config.js",
"test:integration:ui": "vitest --ui --config vitest.integration.config.js"
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
@ -79,6 +82,7 @@
"jsdom": "^25.0.1",
"node-mocks-http": "^1.16.2",
"npm-run-all2": "^7.0.1",
"playwright": "^1.51.1",
"prettier": "^3.0.1",
"superagent": "^9.0.0",
"supertest": "^7.0.0",

View File

@ -83,7 +83,9 @@ export default {
<div v-else-if="message.role === 'tool'">
<div class="tool-message-container">
<el-collapse>
<el-collapse-item title=""></el-collapse-item>
<el-collapse-item title="Tool Message">
{{ message.args }}
</el-collapse-item>
</el-collapse>
</div>
</div>
@ -102,6 +104,17 @@ export default {
max-width: min(600px, calc(100% - 60px));
}
.tool-message-container {
background-color: antiquewhite;
color:black;
padding: 10px;
margin: 10px 0 10px 0;
display: flex;
flex-direction: column;
width: fit-content;
max-width: min(600px, calc(100% - 60px));
}
.user, .assistant {
display: flex;
flex-direction: column;

View File

@ -1,8 +1,8 @@
import type { OpeyMessage } from "./MessageModel";
import type { OpeyMessage, AssistantMessage } from "./MessageModel";
export interface Chat {
messages: OpeyMessage[];
currentAssistantMessage: OpeyMessage;
currentAssistantMessage: AssistantMessage;
status: 'ready' | 'streaming' | 'error' | 'loading';
userIsAuthenticated: boolean;
threadId: string;

View File

@ -57,6 +57,7 @@ export interface UserMessage extends OpeyMessage {
}
export interface AssistantMessage extends OpeyMessage {
toolCalls: ToolMessage[];
// Probably we will need some fields here for tool call/ tool call approval requests
}

View File

@ -159,6 +159,7 @@ export const useChat = defineStore('chat', {
content: '',
role: 'assistant',
id: uuidv4(),
toolCalls: []
}
this.addMessage(this.currentAssistantMessage)
@ -184,13 +185,6 @@ export const useChat = defineStore('chat', {
if (response.status !== 200) {
throw new Error(`Error sending Opey message: ${response.statusText}`);
}
let context = {
currentAssistantMessage: this.currentAssistantMessage,
messages: this.messages,
status: this.status
};
await this._processOpeyStream(stream);
} catch (error) {
@ -233,19 +227,21 @@ export const useChat = defineStore('chat', {
// This is where we process different types of messages from Opey by their 'type' field
// Process pending tool calls
if (data.type === 'message') {
console.log("Tool Calls: ", content)
for (const toolCall of content.tool_calls) {
if (content.tool_calls && content.tool_calls.length > 0) {
console.log("Tool Calls: ", content)
for (const toolCall of content.tool_calls) {
const toolMessage: ToolMessage = {
pending: true,
id: uuidv4(),
role: 'tool',
content: '',
awaitingApproval: false,
toolCall: toolCall
const toolMessage: ToolMessage = {
pending: true,
id: uuidv4(),
role: 'tool',
content: '',
awaitingApproval: false,
toolCall: toolCall
}
this.currentAssistantMessage.toolCalls.push(toolMessage)
}
this.addMessage(toolMessage)
}
}
if (data.type === 'token' && data.content) {
@ -255,7 +251,7 @@ export const useChat = defineStore('chat', {
this.messages = [...this.messages];
}
} catch (e) {
throw new Error(`Error parsing JSON: ${e}`);
throw new Error(`Error parsing JSON: ${e}\n\n${line}`);
}
} else if (line === 'data: [DONE]') {
// Add the current assistant message to the messages list
@ -266,6 +262,7 @@ export const useChat = defineStore('chat', {
id: '',
role: 'assistant',
content: '',
toolCalls: [],
};
}
}

View File

@ -214,6 +214,39 @@ describe('Chat Store _proccessOpeyStream', () => {
expect(toolMessage.content).toBe('')
})
it('should associate tool call with current assistant message', async () => {
// create a mock stream
const mockStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(`data: {"type": "message", "content": {"type": "ai", "content": "", "tool_calls": [{"name": "retrieve_glossary", "args": {"question": "hre"}, "id": "call_XsmUpPIeS81l9MYpieBZtr4w", "type": "tool_call"}], "tool_approval_request": false, "tool_call_id": null, "run_id": "d0c2bcbe-62f7-464b-8564-bf9263939fe1", "original": {"type": "ai", "data": {"content": "", "additional_kwargs": {"tool_calls": [{"index": 0, "id": "call_XsmUpPIeS81l9MYpieBZtr4w", "function": {"arguments": "{\\"question\\":\\"hre\\"}", "name": "retrieve_glossary"}, "type": "function"}]}, "response_metadata": {"finish_reason": "tool_calls", "model_name": "gpt-4o-2024-08-06", "system_fingerprint": "fp_eb9dce56a8"}, "type": "ai", "name": null, "id": "run-5bb065b9-440d-4678-bbdb-cd6de94a78d3", "example": false, "tool_calls": [{"name": "retrieve_glossary", "args": {"question": "hre"}, "id": "call_XsmUpPIeS81l9MYpieBZtr4w", "type": "tool_call"}], "invalid_tool_calls": [], "usage_metadata": null}}}}\n`));
controller.close();
},
});
// mock the fetch function
global.fetch = vi.fn(() =>
Promise.resolve(new Response(mockStream, {
headers: { 'content-type': 'text/event-stream' },
status: 200,
}))
);
chatStore.currentAssistantMessage = {
id: '123',
role: 'assistant',
content: '',
}
await chatStore._processOpeyStream(mockStream)
expect(chatStore.messages).toHaveLength(1)
const toolMessage: ToolMessage = chatStore.messages[0] as ToolMessage
expect(chatStore.currentAssistantMessage.toolCalls).toBeDefined()
expect(chatStore.currentAssistantMessage.toolCalls).toContain(toolMessage.toolCall)
})
it('should throw an error when the chunk is not valid json', async () => {
const invalidJsonStream = new ReadableStream<Uint8Array>({
start(controller) {

View File

@ -0,0 +1,110 @@
import { createServer as createViteServer } from 'vite';
import { afterAll, beforeAll } from 'vitest';
import { ChildProcess, spawn } from 'child_process';
import fetch from 'node-fetch';
import path from 'path';
// Ports for our test servers
const EXPRESS_PORT = 8085; // Match the port in server/app.ts
const VITE_PORT = 8086; // Different from the default dev port
let viteServer: any;
let expressServer: ChildProcess;
/**
* Starts the Express and Vue servers for integration testing
*/
export async function setupTestServers() {
// Start Express server as a separate process
expressServer = spawn('ts-node', ['server/app.ts'], {
stdio: 'pipe',
env: { ...process.env, PORT: EXPRESS_PORT.toString() }
});
// Log server output for debugging
expressServer.stdout?.on('data', (data) => {
console.log(`Express server: ${data}`);
});
expressServer.stderr?.on('data', (data) => {
console.error(`Express server error: ${data}`);
});
// Start Vite dev server in test mode
viteServer = await createViteServer({
configFile: path.resolve(__dirname, '../../../vite.config.mts'),
server: {
port: VITE_PORT,
},
logLevel: 'silent', // Reduce console noise during tests
});
await viteServer.listen(VITE_PORT);
console.log(`Vite test server running at http://localhost:${VITE_PORT}`);
// Wait for both servers to be fully ready
await waitForServer(`http://localhost:${EXPRESS_PORT}/api/status`, 30);
await waitForServer(`http://localhost:${VITE_PORT}`, 30);
return {
expressUrl: `http://localhost:${EXPRESS_PORT}`,
viteUrl: `http://localhost:${VITE_PORT}`,
};
}
/**
* Stops all test servers
*/
export async function teardownTestServers() {
// Close Vite server
if (viteServer) {
await viteServer.close();
}
// Close Express server
if (expressServer) {
expressServer.kill('SIGTERM');
}
}
/**
* Helper to wait for a server to respond
*/
async function waitForServer(url: string, maxRetries = 30): Promise<boolean> {
let retries = 0;
while (retries < maxRetries) {
try {
const response = await fetch(url);
if (response.ok) {
return true;
}
} catch (error) {
// Server not ready yet
console.log(`Waiting for ${url} (attempt ${retries + 1}/${maxRetries})...`);
}
retries++;
await new Promise(resolve => setTimeout(resolve, 1000)); // Increase wait time to 1s
}
throw new Error(`Server at ${url} did not respond in time`);
}
/**
* Setup and teardown hooks for vitest
*/
export function useIntegrationTestHooks() {
let servers: { expressUrl: string; viteUrl: string };
beforeAll(async () => {
servers = await setupTestServers();
return servers;
});
afterAll(async () => {
await teardownTestServers();
});
return () => servers;
}

View File

@ -0,0 +1,95 @@
import { describe, test, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { useIntegrationTestHooks } from './setup';
import { chromium, Browser, Page, BrowserContext } from 'playwright';
describe('API Explorer Integration Tests', () => {
// Setup Express and Vue servers for all tests
const getServers = useIntegrationTestHooks();
let browser: Browser;
let context: BrowserContext;
let page: Page;
// Setup browser for testing
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
// Use this to debug tests visually if needed
// headless: false,
// slowMo: 1000,
});
});
afterAll(async () => {
await browser.close();
});
beforeEach(async () => {
context = await browser.newContext();
page = await context.newPage();
});
afterEach(async () => {
await context.close();
});
test('API status endpoint responds with 200', async () => {
const servers = getServers();
const response = await fetch(`${servers.expressUrl}/api/status`);
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toHaveProperty('status');
});
// Focus on API tests first since they're less complex
test('Backend API endpoints are accessible', async () => {
const servers = getServers();
// Test a few key endpoints
const endpoints = [
'/api/status',
// Add more endpoints as needed
];
for (const endpoint of endpoints) {
const response = await fetch(`${servers.expressUrl}${endpoint}`);
expect(response.status).toBe(200);
}
});
test('Vite development server is accessible', async () => {
const servers = getServers();
const response = await fetch(servers.viteUrl);
expect(response.status).toBe(200);
});
// Comment out the more complex UI tests until the basic setup is working
test.skip('Home page loads correctly', async () => {
const servers = getServers();
await page.goto(servers.viteUrl);
// Wait for the page to load
await page.waitForSelector('title');
// Check that the title contains expected text
const title = await page.title();
expect(title).toContain('API Explorer');
});
test.skip('Chat widget can be opened', async () => {
const servers = getServers();
await page.goto(servers.viteUrl);
// Find and click the chat widget button
const chatButton = await page.waitForSelector('.chat-widget-button');
await chatButton.click();
// Check that the chat container appears
await page.waitForSelector('.chat-container');
const chatContainerExists = await page.isVisible('.chat-container');
expect(chatContainerExists).toBe(true);
});
});

View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
test: {
globals: true,
environment: 'node',
testTimeout: 60000, // Integration tests need more time
hookTimeout: 60000,
include: ['src/test/integration/**/*.integration.test.ts'],
setupFiles: ['src/test/integration/setup.ts'],
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
});