Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 9 additions & 21 deletions tests/modules/ai.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@
addToHistory('ch1', 'user', 'hello');
const history = await getHistoryAsync('ch1');
expect(history.length).toBe(1);
expect(history[0]).toMatchObject({ role: 'user', content: 'hello' });
expect(history[0].timestamp).toEqual(expect.any(Number));
expect(history[0]).toEqual(expect.objectContaining({ role: 'user', content: 'hello' }));
});

it('should hydrate DB history in-place when concurrent messages are added', async () => {
Expand All @@ -75,31 +74,20 @@

resolveHydration({
rows: [
{ role: 'assistant', content: 'db reply', created_at: '2026-04-01T10:00:01.000Z' },
{ role: 'user', content: 'db message', created_at: '2026-04-01T10:00:00.000Z' },
{ role: 'assistant', content: 'db reply' },
{ role: 'user', content: 'db message' },
],
});

await hydrationPromise;
await asyncHistoryPromise;

await vi.waitFor(() => {
expect(historyRef).toHaveLength(3);
expect(historyRef[0]).toMatchObject({
role: 'user',
content: 'db message',
timestamp: Date.parse('2026-04-01T10:00:00.000Z'),
});
expect(historyRef[1]).toMatchObject({
role: 'assistant',
content: 'db reply',
timestamp: Date.parse('2026-04-01T10:00:01.000Z'),
});
expect(historyRef[2]).toMatchObject({
role: 'user',
content: 'concurrent message',
timestamp: expect.any(Number),
});
expect(historyRef).toEqual([
expect.objectContaining({ role: 'user', content: 'db message' }),
expect.objectContaining({ role: 'assistant', content: 'db reply' }),
expect.objectContaining({ role: 'user', content: 'concurrent message' }),
]);
expect(getConversationHistory().get('race-channel')).toBe(historyRef);
});
});
Expand All @@ -118,8 +106,8 @@
expect(history.length).toBe(2);
expect(history[0].content).toBe('from db');
expect(history[1].content).toBe('response');
expect(mockQuery).toHaveBeenCalledWith(

Check failure on line 109 in tests/modules/ai.test.js

View workflow job for this annotation

GitHub Actions / Test

tests/modules/ai.test.js > ai module > getHistoryAsync > should load from DB on cache miss

AssertionError: expected "vi.fn()" to be called with arguments: [ StringContaining{…}, …(1) ] Received: 1st vi.fn() call: [ - StringContaining "SELECT role, content FROM conversations", + "SELECT role, content, created_at FROM conversations + WHERE channel_id = $1 + ORDER BY created_at DESC + LIMIT $2", [ "ch-new", 20, ], ] Number of calls: 1 ❯ tests/modules/ai.test.js:109:25
expect.stringContaining('SELECT role, content, created_at FROM conversations'),
expect.stringContaining('SELECT role, content FROM conversations'),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test asserts wrong SQL query substring after edit

High Severity

The test assertion was changed to expect 'SELECT role, content FROM conversations' but the actual production query in src/modules/ai.js line 224 is 'SELECT role, content, created_at FROM conversations'. Since expect.stringContaining checks for an exact substring, the expected string "SELECT role, content FROM conversations" is NOT a substring of the actual query (which has , created_at between content and FROM). This test will always fail.

Fix in Cursor Fix in Web

Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion expects the SQL query to omit created_at, but src/modules/ai.js still selects created_at when hydrating history (SELECT role, content, created_at FROM conversations ...). Update the expectation (or the implementation) so the test matches the actual query.

Suggested change
expect.stringContaining('SELECT role, content FROM conversations'),
expect.stringContaining('SELECT role, content, created_at FROM conversations'),

Copilot uses AI. Check for mistakes.
['ch-new', 20],
);
});
Expand Down
66 changes: 63 additions & 3 deletions web/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,66 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
import { getAuthOptions } from '@/lib/auth';
import { logger } from '@/lib/logger';

const handler = NextAuth(authOptions);
function fallbackAuthResponse(request: NextRequest, error: unknown) {
const pathname = request.nextUrl.pathname;
logger.warn('[auth] Auth route requested without valid environment configuration', {
pathname,
error: error instanceof Error ? error.message : String(error),
});

export { handler as GET, handler as POST };
if (pathname.endsWith('/session')) {
return NextResponse.json({}, { status: 200 });
}

if (pathname.endsWith('/providers')) {
return NextResponse.json({}, { status: 200 });
}

if (pathname.endsWith('/csrf')) {
return NextResponse.json({ csrfToken: '' }, { status: 200 });
}

return NextResponse.json({ error: 'AuthUnavailable' }, { status: 503 });
}

// Cache the NextAuth handler after the first successful creation to avoid
// reconstructing it on every request (getAuthOptions() is already cached internally).
let cachedHandler: ReturnType<typeof NextAuth> | undefined;

function getHandler() {
if (!cachedHandler) {
cachedHandler = NextAuth(getAuthOptions());
}
return cachedHandler;
}

async function handleAuth(
request: NextRequest,
context: { params: Promise<{ nextauth: string[] }> },
) {
try {
const handler = getHandler();
return await handler(request, context);
} catch (error) {
// Reset cache on failure so next request retries handler creation
cachedHandler = undefined;
return fallbackAuthResponse(request, error);
}
}

export async function GET(
request: NextRequest,
context: { params: Promise<{ nextauth: string[] }> },
) {
return handleAuth(request, context);
}

export async function POST(
request: NextRequest,
context: { params: Promise<{ nextauth: string[] }> },
) {
return handleAuth(request, context);
}
209 changes: 149 additions & 60 deletions web/src/components/dashboard/config-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,91 +1,180 @@
'use client';

import { Save } from 'lucide-react';
import { ConfigProvider, useConfigContext } from '@/components/dashboard/config-context';
import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
import { SELECTED_GUILD_KEY } from '@/lib/guild-selection';
import type { GuildConfig } from './config-editor-utils';
import { isGuildConfig } from './config-editor-utils';
import { DiscardChangesButton } from './reset-defaults-button';
import { SystemPromptEditor } from './system-prompt-editor';

export function ConfigEditor() {
return (
<ConfigProvider>
<ConfigEditorContent />
</ConfigProvider>
);
function getSelectedGuildId(): string {
try {
return localStorage.getItem(SELECTED_GUILD_KEY) ?? '';
} catch {
return '';
}
}

function ConfigEditorContent() {
const {
guildId,
draftConfig,
loading,
error,
saving,
hasChanges,
hasValidationErrors,
openDiffModal,
discardChanges,
fetchConfig,
updateDraftConfig,
} = useConfigContext();

if (!guildId) {
return <div className="p-6">Select a server to manage its configuration.</div>;
}
export function ConfigEditor() {
const [draftConfig, setDraftConfig] = useState<GuildConfig | null>(null);
const [savedConfig, setSavedConfig] = useState<GuildConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasChanges, setHasChanges] = useState(false);

useEffect(() => {
const guildId = getSelectedGuildId();
if (!guildId) {
setLoading(false);
setDraftConfig({});
return;
}

let cancelled = false;

async function loadConfig() {
setLoading(true);
setError(null);

try {
const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/config`, {
cache: 'no-store',
});

if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}

const data: unknown = await res.json();
if (!isGuildConfig(data)) {
throw new Error('Invalid config response');
}
Comment on lines +49 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Save error unmounts the entire editor, trapping the user's draft

error is shared between load errors and save errors. When saveChanges catches and calls setError(...), the if (error) return <div role="alert">{error}</div> early return fires on the next render, replacing the full editor with a bare error string. The user's unsaved edits disappear with no retry path — they must hard-refresh and re-enter their changes.

The fix is to use a separate piece of state for in-editor save errors and render it inline (e.g. next to the Save button), keeping the load-error early return for the initial failure case only.

Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/components/dashboard/config-editor.tsx
Line: 49-52

Comment:
**Save error unmounts the entire editor, trapping the user's draft**

`error` is shared between load errors and save errors. When `saveChanges` catches and calls `setError(...)`, the `if (error) return <div role="alert">{error}</div>` early return fires on the next render, replacing the full editor with a bare error string. The user's unsaved edits disappear with no retry path — they must hard-refresh and re-enter their changes.

The fix is to use a separate piece of state for in-editor save errors and render it inline (e.g. next to the Save button), keeping the load-error early return for the initial failure case only.

How can I resolve this? If you propose a fix, please make it concise.


if (!cancelled) {
setDraftConfig(data);
setSavedConfig(data);
setHasChanges(false);
}
} catch (err) {
if (!cancelled) {
setError((err as Error).message || 'Failed to load config');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}

void loadConfig();

return () => {
cancelled = true;
};
}, []);

if (loading) {
return <div className="p-6">Loading configuration...</div>;
return <div>Loading configuration</div>;
}

if (error) {
return (
<div className="space-y-4 p-6">
<p>{error}</p>
<Button variant="outline" onClick={() => fetchConfig(guildId)}>
Retry
</Button>
</div>
);
return <div role="alert">{error}</div>;
}

async function saveChanges() {
const guildId = getSelectedGuildId();
if (!guildId || !draftConfig) {
return;
}

setSaving(true);
setError(null);

try {
const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/config`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: 'ai.systemPrompt',
value: draftConfig.ai?.systemPrompt ?? '',
}),
});

if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}

const updatedSection: unknown = await res.json();
const nextDraftConfig = {
...(draftConfig ?? {}),

Check warning on line 110 in web/src/components/dashboard/config-editor.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The empty object is useless.

See more on https://sonarcloud.io/project/issues?id=VolvoxLLC_volvox-bot&issues=AZ1PRMESFUyP9qXTsxh3&open=AZ1PRMESFUyP9qXTsxh3&pullRequest=423
ai: updatedSection && typeof updatedSection === 'object' ? updatedSection : draftConfig.ai,
} satisfies GuildConfig;

setDraftConfig(nextDraftConfig);
setSavedConfig(nextDraftConfig);
setHasChanges(false);
} catch (err) {
setError((err as Error).message || 'Failed to save config');
} finally {
setSaving(false);
}
}

if (!draftConfig) {
return null;
function discardChanges() {
if (!savedConfig) {
return;
}

setDraftConfig(structuredClone(savedConfig));
setHasChanges(false);
}

return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between gap-4">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Bot Configuration</h1>
<p className="text-sm text-muted-foreground">Manage core bot settings in one place.</p>
<h1 className="text-2xl font-semibold">Bot Configuration</h1>
<p className="text-sm text-muted-foreground">Manage guild configuration sections.</p>
</div>
<div className="flex items-center gap-2">
<DiscardChangesButton onReset={discardChanges} disabled={saving || !hasChanges} />
<Button onClick={openDiffModal} disabled={saving || !hasChanges || hasValidationErrors}>
<Save className="mr-2 h-4 w-4" />
Save Changes
</Button>
<DiscardChangesButton
onReset={discardChanges}
disabled={!hasChanges || saving}
sectionLabel="all unsaved changes"
/>
<button
type="button"
onClick={() => void saveChanges()}
disabled={!hasChanges || saving}
className="rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
</div>
</div>

<section className="space-y-3">
<h2 className="text-lg font-semibold">AI Chat</h2>
<section className="space-y-2">
<h2 className="text-lg font-medium">AI Chat</h2>
<SystemPromptEditor
value={draftConfig.ai?.systemPrompt ?? ''}
onChange={(value) =>
updateDraftConfig((prev) => ({
...prev,
ai: { ...prev.ai, systemPrompt: value },
}))
}
value={draftConfig?.ai?.systemPrompt ?? ''}
onChange={(systemPrompt) => {
setDraftConfig((prev) => ({
...(prev ?? {}),

Check warning on line 163 in web/src/components/dashboard/config-editor.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The empty object is useless.

See more on https://sonarcloud.io/project/issues?id=VolvoxLLC_volvox-bot&issues=AZ1PKqQmuy6R6soH-Weh&open=AZ1PKqQmuy6R6soH-Weh&pullRequest=423
ai: {
...(prev?.ai ?? {}),

Check warning on line 165 in web/src/components/dashboard/config-editor.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The empty object is useless.

See more on https://sonarcloud.io/project/issues?id=VolvoxLLC_volvox-bot&issues=AZ1PKqQmuy6R6soH-Wei&open=AZ1PKqQmuy6R6soH-Wei&pullRequest=423
systemPrompt,
},
}));
setHasChanges(true);
}}
/>
</section>

{/* TODO: Integrate DiscordMarkdownEditor for welcome message template editing */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Welcome Messages</h2>
<section className="space-y-2">
<h2 className="text-lg font-medium">Welcome Messages</h2>
<p className="text-sm text-muted-foreground">
Configure welcome message templates for new server members.
Welcome message configuration is available in the settings workspace.
</p>
</section>
</div>
Expand Down
Loading
Loading