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
136 changes: 136 additions & 0 deletions src/server/tools/guided-query-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { getPineconeClient } from '../client-context.js';
import { getNamespacesWithCache } from '../namespaces-cache.js';
import { registerGuidedQueryTool } from './guided-query-tool.js';
import {
createMockServer,
makeNamespaceCacheEntry,
makeSearchResult,
parseToolJson,
} from './test-helpers.js';

vi.mock('../client-context.js', () => ({
getPineconeClient: vi.fn(),
}));

vi.mock('../namespaces-cache.js', () => ({
getNamespacesWithCache: vi.fn(),
}));

/** Real `markSuggested` may call `getServerConfig()` during sweep (CI has no API key); isolate the handler. */
vi.mock('../suggestion-flow.js', () => ({
markSuggested: vi.fn(),
}));

const mockedGetNamespaces = vi.mocked(getNamespacesWithCache);
const mockedGetClient = vi.mocked(getPineconeClient);

describe('guided_query tool handler', () => {
const nsEntry = makeNamespaceCacheEntry('papers', {
document_number: 'string',
title: 'string',
url: 'string',
author: 'string',
chunk_text: 'string',
});

beforeEach(() => {
vi.clearAllMocks();
mockedGetNamespaces.mockResolvedValue({
data: [nsEntry],
cache_hit: false,
expires_at: Date.now() + 1_800_000,
});
mockedGetClient.mockReturnValue({
query: vi.fn().mockResolvedValue([makeSearchResult()]),
count: vi.fn().mockResolvedValue({ count: 7, truncated: false }),
} as never);
});

it('runs query_detailed path on auto when user asks for content', async () => {
const server = createMockServer();
registerGuidedQueryTool(server as never);
const query = mockedGetClient().query as ReturnType<typeof vi.fn>;

const body = parseToolJson(
await server.getHandler('guided_query')!({
user_query: 'What does the paper say about contracts?',
namespace: 'papers',
top_k: 8,
preferred_tool: 'auto',
enrich_urls: false,
})
);

expect(body.status).toBe('success');
const trace = body.decision_trace as Record<string, unknown>;
expect(trace.selected_namespace).toBe('papers');
expect(trace.selected_tool).toBe('detailed');
expect(query).toHaveBeenCalledWith(
expect.objectContaining({
namespace: 'papers',
topK: 8,
useReranking: true,
})
);
const result = body.result as Record<string, unknown>;
expect(result.mode).toBe('query_detailed');
});

it('runs count when preferred_tool is count', async () => {
const server = createMockServer();
registerGuidedQueryTool(server as never);
const count = mockedGetClient().count as ReturnType<typeof vi.fn>;

const body = parseToolJson(
await server.getHandler('guided_query')!({
user_query: 'browse',
namespace: 'papers',
preferred_tool: 'count',
})
);

expect(count).toHaveBeenCalledWith(
expect.objectContaining({
query: 'browse',
namespace: 'papers',
})
);
const result = body.result as Record<string, unknown>;
expect(result.tool).toBe('count');
expect(result.count).toBe(7);
});

it('returns error when user_query is empty', async () => {
const server = createMockServer();
registerGuidedQueryTool(server as never);

const raw = await server.getHandler('guided_query')!({
user_query: ' ',
namespace: 'papers',
});

expect((raw as { isError?: boolean }).isError).toBe(true);
expect(parseToolJson(raw).message).toBe('user_query cannot be empty');
});

it('returns error when no namespace can be resolved', async () => {
mockedGetNamespaces.mockResolvedValue({
data: [],
cache_hit: false,
expires_at: Date.now() + 1_800_000,
});

const server = createMockServer();
registerGuidedQueryTool(server as never);

const body = parseToolJson(
await server.getHandler('guided_query')!({
user_query: 'hello world',
})
);

expect(body.status).toBe('error');
expect(String(body.message)).toContain('No namespace available');
});
});
72 changes: 72 additions & 0 deletions src/server/tools/list-namespaces-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getNamespacesWithCache } from '../namespaces-cache.js';
import { registerListNamespacesTool } from './list-namespaces-tool.js';
import { createMockServer, parseToolJson } from './test-helpers.js';

vi.mock('../namespaces-cache.js', () => ({
getNamespacesWithCache: vi.fn(),
}));

const mockedGetNamespaces = vi.mocked(getNamespacesWithCache);

describe('list_namespaces tool handler', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns success with namespaces on happy path', async () => {
const expiresAt = Date.now() + 1_800_000;
mockedGetNamespaces.mockResolvedValue({
data: [
{ namespace: 'a', recordCount: 1, metadata: { title: 'string' } },
{ namespace: 'b', recordCount: 2, metadata: { url: 'string' } },
],
cache_hit: false,
expires_at: expiresAt,
});

const server = createMockServer();
registerListNamespacesTool(server as never);
const handler = server.getHandler('list_namespaces')!;
const raw = await handler({});

const body = parseToolJson(raw);
expect(body.status).toBe('success');
expect(body.cache_hit).toBe(false);
expect(body.count).toBe(2);
expect(body.namespaces).toEqual([
{ name: 'a', record_count: 1, metadata_fields: { title: 'string' } },
{ name: 'b', record_count: 2, metadata_fields: { url: 'string' } },
]);
expect(typeof body.cache_ttl_seconds).toBe('number');
});

it('propagates cache_hit when namespaces cache is warm', async () => {
mockedGetNamespaces.mockResolvedValue({
data: [{ namespace: 'x', recordCount: 0, metadata: {} }],
cache_hit: true,
expires_at: Date.now() + 60_000,
});

const server = createMockServer();
registerListNamespacesTool(server as never);
const body = parseToolJson(await server.getHandler('list_namespaces')!({}));

expect(body.cache_hit).toBe(true);
expect(body.count).toBe(1);
});

it('returns error payload when getNamespacesWithCache throws', async () => {
mockedGetNamespaces.mockRejectedValue(new Error('network down'));

const server = createMockServer();
registerListNamespacesTool(server as never);
const raw = await server.getHandler('list_namespaces')!({});
const payload = raw as { isError?: boolean };

expect(payload.isError).toBe(true);
const body = parseToolJson(raw);
expect(body.status).toBe('error');
expect(String(body.message)).toBe('Failed to list namespaces');
});
});
137 changes: 137 additions & 0 deletions src/server/tools/query-documents-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { DEFAULT_QUERY_DOCUMENTS_TOP_K, QUERY_DOCUMENTS_MAX_CHUNKS } from '../../constants.js';
import { getPineconeClient } from '../client-context.js';
import { reassembleByDocument } from '../reassemble-documents.js';
import * as suggestionFlow from '../suggestion-flow.js';
import { registerQueryDocumentsTool } from './query-documents-tool.js';
import { createMockServer, makeSearchResult, parseToolJson } from './test-helpers.js';

vi.mock('../client-context.js', () => ({
getPineconeClient: vi.fn(),
}));

vi.mock('../reassemble-documents.js', () => ({
reassembleByDocument: vi.fn(),
}));

const mockedGetClient = vi.mocked(getPineconeClient);
const mockedReassemble = vi.mocked(reassembleByDocument);

describe('query_documents tool handler', () => {
const flowOk = {
ok: true as const,
flow: {
updatedAt: Date.now(),
recommended_tool: 'detailed' as const,
suggested_fields: [],
user_query: 'q',
},
};

beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue(flowOk);
mockedReassemble.mockReturnValue([
{
document_id: 'D1',
merged_content: 'full doc text',
metadata: { document_number: 'D1' },
chunk_count: 3,
best_score: 0.99,
},
]);
mockedGetClient.mockReturnValue({
query: vi.fn().mockResolvedValue([makeSearchResult()]),
} as never);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('happy path: queries chunks, reassembles, and returns documents', async () => {
const server = createMockServer();
registerQueryDocumentsTool(server as never);
const query = mockedGetClient().query as ReturnType<typeof vi.fn>;

const body = parseToolJson(
await server.getHandler('query_documents')!({
query_text: 'semantic question',
namespace: 'wg21',
top_k: DEFAULT_QUERY_DOCUMENTS_TOP_K,
})
);

const expectedTopK = Math.min(QUERY_DOCUMENTS_MAX_CHUNKS, DEFAULT_QUERY_DOCUMENTS_TOP_K * 50);
expect(query).toHaveBeenCalledWith(
expect.objectContaining({
query: 'semantic question',
namespace: 'wg21',
topK: expectedTopK,
useReranking: true,
fields: undefined,
})
);
expect(mockedReassemble).toHaveBeenCalled();
expect(body.status).toBe('success');
const docs = body.documents as Array<{ merged_content: string }>;
expect(docs[0].merged_content).toBe('full doc text');
});

it('returns error when query_text is empty', async () => {
const server = createMockServer();
registerQueryDocumentsTool(server as never);
const query = mockedGetClient().query as ReturnType<typeof vi.fn>;

const raw = await server.getHandler('query_documents')!({
query_text: '',
namespace: 'wg21',
});

expect((raw as { isError?: boolean }).isError).toBe(true);
expect(query).not.toHaveBeenCalled();
expect(parseToolJson(raw).message).toBe('query_text cannot be empty');
});

it('returns flow error when suggest_query_params gate fails', async () => {
vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({
ok: false,
message:
'Flow requires suggest_query_params first. Call suggest_query_params with namespace and user_query before query/count tools.',
});

const server = createMockServer();
registerQueryDocumentsTool(server as never);
const query = mockedGetClient().query as ReturnType<typeof vi.fn>;

const body = parseToolJson(
await server.getHandler('query_documents')!({
query_text: 'ok',
namespace: 'wg21',
})
);

expect(body.status).toBe('error');
expect(query).not.toHaveBeenCalled();
});

it('returns TTL expiry error from requireSuggested', async () => {
vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({
ok: false,
message:
'Previous suggest_query_params context expired (30 minutes). Call suggest_query_params again before query/count tools.',
});

const server = createMockServer();
registerQueryDocumentsTool(server as never);

const body = parseToolJson(
await server.getHandler('query_documents')!({
query_text: 'ok',
namespace: 'wg21',
})
);

expect(body.message).toContain('expired');
});
});
Loading
Loading