Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8bdff4e
Improve accessibility and styling in chat components; add mocks for n…
qduc Aug 30, 2025
852e15a
Add support for system prompts in conversation metadata and enhance r…
qduc Aug 30, 2025
8346cb9
Enhance test setup by saving and restoring provider configuration in …
qduc Aug 30, 2025
f823e29
Refactor chat proxy tests: Split tests into separate files and create…
qduc Aug 30, 2025
3dd12d2
Refactor test utilities: Move chat proxy test helpers to a dedicated …
qduc Aug 30, 2025
1b355dd
Refactor SettingsModal: Clean up whitespace, improve accessibility wi…
qduc Aug 31, 2025
2927383
Refactor SettingsModal: Adjust layout and styling for improved UI con…
qduc Aug 31, 2025
2ffe58b
Refactor components: Update background opacity for ChatHeader, ChatSi…
qduc Aug 31, 2025
8be7c5c
Add behavior tests for chat proxy validation, tools endpoint, convers…
qduc Aug 31, 2025
ec1224a
Refactor `providersRouter` to support dependency injection and improv…
qduc Aug 31, 2025
b44ec78
Refactor HTTP client usage in providers and orchestration logic
qduc Aug 31, 2025
4de4a17
Migrate tests to use `supertest` for HTTP requests
qduc Aug 31, 2025
c016d9d
Add supertest-based tests for iterative orchestration logic
qduc Aug 31, 2025
ed6390d
Simplify and streamline test suites for iterative orchestration
qduc Aug 31, 2025
175fd91
Update ESLint config and streamline test formatting
qduc Aug 31, 2025
66c039e
Refactor code formatting and improve Markdown renderer readability
qduc Aug 31, 2025
1cb9565
Add socket tracking and cleanup for graceful server shutdown in tests
qduc Aug 31, 2025
7a68903
Split CI workflow into separate backend and frontend jobs
qduc Aug 31, 2025
359edbe
Initial plan
Copilot Aug 31, 2025
63c6bda
Fix open handles in iterative orchestration tests by adding proper re…
Copilot Aug 31, 2025
a5aef9c
Merge pull request #20 from qduc/copilot/fix-1b397a2e-8d4a-4bf1-ba52-…
qduc Aug 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ on:
branches: [ main ]

jobs:
build-and-test:
backend:
runs-on: ubuntu-latest
# Cancel the job if it runs longer than this (in minutes). Adjust as needed.
timeout-minutes: 3

steps:
Expand All @@ -18,10 +17,7 @@ jobs:
with:
node-version: '20'
cache: 'npm'
# Cache based on lockfiles in subprojects
cache-dependency-path: |
backend/package-lock.json
frontend/package-lock.json
cache-dependency-path: backend/package-lock.json

- name: Install Backend Dependencies
run: npm --prefix backend install
Expand All @@ -33,6 +29,20 @@ jobs:
run: npm --prefix backend test
timeout-minutes: 1

frontend:
runs-on: ubuntu-latest
timeout-minutes: 3

steps:
- uses: actions/checkout@v4

- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json

- name: Install Frontend Dependencies
run: npm --prefix frontend install

Expand All @@ -42,3 +52,21 @@ jobs:
- name: Test Frontend
run: npm --prefix frontend test
timeout-minutes: 1

# This job will only run if both backend and frontend jobs succeed
# The workflow will fail if either backend or frontend fails
check-results:
runs-on: ubuntu-latest
needs: [backend, frontend]
if: always()

steps:
- name: Check all jobs succeeded
run: |
if [[ "${{ needs.backend.result }}" != "success" || "${{ needs.frontend.result }}" != "success" ]]; then
echo "One or more jobs failed:"
echo "Backend: ${{ needs.backend.result }}"
echo "Frontend: ${{ needs.frontend.result }}"
exit 1
fi
echo "All jobs succeeded!"
237 changes: 237 additions & 0 deletions backend/__tests__/chat_proxy.format.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// Format transformation and tool orchestration tests
import assert from 'node:assert/strict';
import request from 'supertest';
import { createChatProxyTestContext, MockUpstream } from '../test_utils/chatProxyTestUtils.js';
import { getDb, upsertSession, createConversation } from '../src/db/index.js';
import { config } from '../src/env.js';

const { makeApp, withServer } = createChatProxyTestContext();

Check warning on line 8 in backend/__tests__/chat_proxy.format.test.js

View workflow job for this annotation

GitHub Actions / backend

'withServer' is assigned a value but never used

describe('Format transformation', () => {
test('converts Responses API non-streaming JSON to Chat Completions shape when hitting /v1/chat/completions', async () => {
const app = makeApp();
const res = await request(app)
.post('/v1/chat/completions')
.send({ messages: [{ role: 'user', content: 'Hello' }], stream: false });
assert.equal(res.status, 200);
const body = res.body;
assert.ok(body.choices);
assert.ok(body.choices[0].message);
assert.equal(body.choices[0].message.role, 'assistant');
assert.ok(body.choices[0].message.content);
});

test('converts Responses API streaming events to Chat Completions chunks when hitting /v1/chat/completions', async () => {
const app = makeApp();
const res = await request(app)
.post('/v1/chat/completions')
.send({ messages: [{ role: 'user', content: 'Hello' }], stream: true });
assert.equal(res.status, 200);
const text = res.text;
assert.ok(text.includes('data: '));
assert.ok(text.includes('[DONE]'));
assert.ok(text.includes('delta'));
});
});

describe('Tool orchestration', () => {
test('handles requests with tools by forcing Chat Completions path', async () => {
const app = makeApp();
const res = await request(app)
.post('/v1/chat/completions')
.send({
messages: [{ role: 'user', content: 'What time is it?' }],
tools: [{ type: 'function', function: { name: 'get_time' } }],
stream: false,
});
assert.equal(res.status, 200);
const body = res.body;
assert.ok(body.choices);
assert.ok(body.choices[0].message);
});

test('tool orchestration paths are covered in code', async () => {
const app = makeApp();
const res = await request(app)
.post('/v1/chat/completions')
.send({
messages: [{ role: 'user', content: 'Hello' }],
tools: [{ type: 'function', function: { name: 'get_time' } }],
stream: true,
});
assert.equal(res.status, 200);
const text = res.text;
assert.ok(text.includes('data:'), 'Should deliver streaming data');
assert.ok(text.includes('[DONE]'), 'Should signal completion');
});

test('persistence works with tool requests', async () => {
const sessionId = 'test-session';
const db = getDb();

Check warning on line 70 in backend/__tests__/chat_proxy.format.test.js

View workflow job for this annotation

GitHub Actions / backend

'db' is assigned a value but never used
upsertSession(sessionId);
createConversation({ id: 'conv1', sessionId, title: 'Test' });

const app = makeApp();
const res = await request(app)
.post('/v1/chat/completions')
.set('x-session-id', sessionId)
.send({
messages: [{ role: 'user', content: 'What time is it?' }],
conversation_id: 'conv1',
tools: [{ type: 'function', function: { name: 'get_time' } }],
stream: false,
});
assert.equal(res.status, 200);
const body = res.body;
assert.ok(body.choices);
assert.ok(body.choices[0].message);
});

test('supports iterative orchestration streaming with tool calls and outputs', async () => {
const upstream = new MockUpstream();

// Custom upstream behavior to simulate iterative orchestration
let callCount = 0;
upstream.app.post('/v1/chat/completions', (req, res) => {
callCount++;
if (callCount === 1) {
res.setHeader('Content-Type', 'text/event-stream');
res.write('data: ' + JSON.stringify({
id: 'iter_1', object: 'chat.completion.chunk', created: Math.floor(Date.now()/1000), model: 'gpt-3.5-turbo',
choices: [{ index: 0, delta: { tool_calls: [ { id: 'call_time', type: 'function', function: { name: 'get_time', arguments: '{}' } } ] }, finish_reason: null }]
}) + '\n\n');
res.write('data: ' + JSON.stringify({ id: 'iter_1_end', object: 'chat.completion.chunk', created: Math.floor(Date.now()/1000), model: 'gpt-3.5-turbo', choices: [{ index: 0, delta: {}, finish_reason: null }] }) + '\n\n');
res.write('data: [DONE]\n\n');
res.end();
} else {
res.json({
id: 'chat_iter_final',
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: 'gpt-3.5-turbo',
choices: [{
index: 0,
message: {
role: 'assistant',
content: 'The current time is 08:30:32 UTC.',
tool_calls: null
},
finish_reason: 'stop'
}]
});
}
});

await upstream.start();

try {
const app = makeApp();
// Ensure provider resolution uses env-config instead of DB rows
try { const db = getDb(); db.exec('DELETE FROM providers;'); } catch {}
const originalBaseUrl = config.openaiBaseUrl;
const originalProviderBase = config.providerConfig.baseUrl;
config.openaiBaseUrl = `http://127.0.0.1:${upstream.port}/v1`;
config.providerConfig.baseUrl = `http://127.0.0.1:${upstream.port}`;

try {
const res = await request(app)
.post('/v1/chat/completions')
.send({
messages: [{ role: 'user', content: 'What time is it?' }],
tools: [{
type: 'function',
function: {
name: 'get_time',
description: 'Get the current time',
parameters: { type: 'object', properties: {} }
}
}],
stream: true,
});
assert.equal(res.status, 200);
const streamData = res.text;
assert(streamData.includes('data:'), 'Should stream SSE data');
assert(streamData.includes('[DONE]'), 'Should end with DONE marker');
} finally {
config.openaiBaseUrl = originalBaseUrl;
config.providerConfig.baseUrl = originalProviderBase;
}
} finally {
await upstream.stop();
}
});

test('handles tool execution within iterative orchestration', async () => {
const upstream = new MockUpstream();
upstream.app.post('/v1/chat/completions', (req, res) => {
// Stream a single tool call event
res.setHeader('Content-Type', 'text/event-stream');
res.write('data: ' + JSON.stringify({
id: 'chat_tool', object: 'chat.completion.chunk', created: Math.floor(Date.now()/1000), model: 'gpt-3.5-turbo',
choices: [{ index: 0, delta: { tool_calls: [ { id: 'call_time', type: 'function', function: { name: 'get_time', arguments: '{}' } } ] }, finish_reason: null }]
}) + '\n\n');
res.write('data: ' + JSON.stringify({ id: 'tool_end', object: 'chat.completion.chunk', created: Math.floor(Date.now()/1000), model: 'gpt-3.5-turbo', choices: [{ index: 0, delta: {}, finish_reason: null }] }) + '\n\n');
res.write('data: [DONE]\n\n');
res.end();
});

await upstream.start();

try {
const app = makeApp();
// Ensure provider resolution uses env-config instead of DB rows
try { const db = getDb(); db.exec('DELETE FROM providers;'); } catch {}
const originalBaseUrl = config.openaiBaseUrl;
const originalProviderBase = config.providerConfig.baseUrl;
config.openaiBaseUrl = `http://127.0.0.1:${upstream.port}/v1`;
config.providerConfig.baseUrl = `http://127.0.0.1:${upstream.port}`;

try {
const res = await request(app)
.post('/v1/chat/completions')
.send({
messages: [{ role: 'user', content: 'Get time' }],
tools: [{
type: 'function',
function: {
name: 'get_time',
description: 'Get current time',
parameters: { type: 'object', properties: {} }
}
}],
stream: true,
});
assert.equal(res.status, 200);
const streamData = res.text;
assert(streamData.includes('data:'), 'Should stream SSE data');
assert(streamData.includes('[DONE]'), 'Should end with DONE marker');
} finally {
config.openaiBaseUrl = originalBaseUrl;
config.providerConfig.baseUrl = originalProviderBase;
}
} finally {
await upstream.stop();
}
});

test('falls back gracefully when no tools provided', async () => {
const app = makeApp();
const res = await request(app)
.post('/v1/chat/completions')
.send({ messages: [{ role: 'user', content: 'Hello' }], stream: true });
assert.equal(res.status, 200);
const streamData = res.text;
const events = streamData
.split('\n')
.filter(line => line.startsWith('data: ') && line !== 'data: [DONE]')
.map(line => { try { return JSON.parse(line.slice(6)); } catch { return null; } })
.filter(Boolean);
const hasToolCalls = events.some(e => e.choices?.[0]?.delta?.tool_calls);
const hasToolOutput = events.some(e => e.choices?.[0]?.delta?.tool_output);
const contentJoined = events.map(e => e.choices?.[0]?.delta?.content || '').join('');
const hasAnyContent = contentJoined.length > 0;
assert(!hasToolCalls, 'Should not have tool call events');
assert(!hasToolOutput, 'Should not have tool output events');
assert(hasAnyContent, 'Should have regular chat response content');
});
});
95 changes: 95 additions & 0 deletions backend/__tests__/chat_proxy.persistence.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Persistence-related tests for chat proxy
import assert from 'node:assert/strict';
import request from 'supertest';
import { createChatProxyTestContext } from '../test_utils/chatProxyTestUtils.js';
import { getDb, upsertSession, createConversation } from '../src/db/index.js';
import { config } from '../src/env.js';

const { makeApp, withServer } = createChatProxyTestContext();

describe('Chat proxy persistence', () => {
test('user receives appropriate error when conversation message limit exceeded', async () => {
const originalLimit = config.persistence.maxMessagesPerConversation;
config.persistence.maxMessagesPerConversation = 1; // Set very low limit

const sessionId = 'test-session-limit';

try {
const db = getDb();
upsertSession(sessionId);
createConversation({ id: 'conv1', sessionId, title: 'Test Limit' });

// Pre-populate one message to reach the limit
db.prepare(
`INSERT INTO messages (conversation_id, role, content, seq) VALUES (?, 'user', 'existing message', 1)`
).run('conv1');

const app = makeApp();
await withServer(app, async (port) => {

Check warning on line 28 in backend/__tests__/chat_proxy.persistence.test.js

View workflow job for this annotation

GitHub Actions / backend

'port' is defined but never used. Allowed unused args must match /^_/u
// Suppress console.error for this specific test
const originalConsoleError = console.error;
console.error = () => {};

try {
const res = await request(app)
.post('/v1/chat/completions')
.set('x-session-id', sessionId)
.send({ messages: [{ role: 'user', content: 'This should be blocked' }], conversation_id: 'conv1', stream: false });
assert.equal(res.status, 429, 'Should return 429 when limit exceeded');
const body = res.body;
assert.equal(body.error, 'limit_exceeded', 'Should indicate limit exceeded');
assert.ok(body.message, 'Should provide explanatory message to user');
} finally {
console.error = originalConsoleError;
}
});
} finally {
config.persistence.maxMessagesPerConversation = originalLimit;
}
});

test('accepts optional conversation_id in body/header and continues streaming', async () => {
const sessionId = 'test-session';
const db = getDb();

Check warning on line 53 in backend/__tests__/chat_proxy.persistence.test.js

View workflow job for this annotation

GitHub Actions / backend

'db' is assigned a value but never used
upsertSession(sessionId);
createConversation({ id: 'conv1', sessionId, title: 'Test' });

const app = makeApp();
const res = await request(app)
.post('/v1/chat/completions')
.set('x-session-id', sessionId)
.set('x-conversation-id', 'conv1')
.send({ messages: [{ role: 'user', content: 'Hello' }], stream: true });
assert.equal(res.status, 200);
assert.ok(res.text.includes('data: '));
});

test('user can retrieve persisted conversation messages after sending a message', async () => {
const sessionId = 'test-session';
const db = getDb();

Check warning on line 69 in backend/__tests__/chat_proxy.persistence.test.js

View workflow job for this annotation

GitHub Actions / backend

'db' is assigned a value but never used
upsertSession(sessionId);
createConversation({ id: 'conv1', sessionId, title: 'Test' });

const app = makeApp();
// User sends a message
const chatRes = await request(app)
.post('/v1/chat/completions')
.set('x-session-id', sessionId)
.send({ messages: [{ role: 'user', content: 'Hello' }], conversation_id: 'conv1', stream: false });
assert.equal(chatRes.status, 200);
assert.ok(chatRes.body.choices[0].message.content);

// Retrieve the conversation messages
const getRes = await request(app).get('/v1/conversations/conv1/messages').set('x-session-id', sessionId);
if (getRes.status === 200) {
const messages = (getRes.body.messages || []);
assert.ok(messages.length >= 2, 'Should persist both user and assistant messages');
const userMessage = messages.find(m => m.role === 'user');
const assistantMessage = messages.find(m => m.role === 'assistant');
assert.ok(userMessage, 'Should persist user message');
assert.equal(userMessage.content, 'Hello', 'Should preserve user message content');
assert.ok(assistantMessage, 'Should persist assistant response');
assert.ok(assistantMessage.content, 'Assistant message should have content');
}
});
});
Loading