Skip to content

Commit 8ece0b6

Browse files
Merge pull request #17 from anand-testcompare/16-doc-list-tool-pagination-and-query-limit
fix: paginate list_all_docs
2 parents df56d6d + 163cc05 commit 8ece0b6

5 files changed

Lines changed: 499 additions & 58 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ data/*.parquet
1717
*.tgz
1818

1919
.sisyphus/
20+
21+
# Local OpenCode session transcripts (generated during manual testing)
22+
session-*.md

src/__tests__/index.test.ts

Lines changed: 190 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,10 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { mock } from 'bun:test';
32
import fs from 'node:fs';
43
import path from 'node:path';
54
import os from 'node:os';
65
import { writeParquet } from '../docs/write-parquet.ts';
76
import * as fetchModule from '../docs/fetch.ts';
87

9-
mock.module('@opencode-ai/plugin/tool', () => {
10-
const mockSchema = {
11-
string: () => ({
12-
describe: (d: string) => ({ _type: 'string', _description: d }),
13-
}),
14-
};
15-
const toolFn = Object.assign((input: Record<string, unknown>) => input, {
16-
schema: mockSchema,
17-
});
18-
return { tool: toolFn };
19-
});
20-
218
// eslint-disable-next-line @typescript-eslint/no-explicit-any
229
const plugin = (await import('../index.ts')).default as any;
2310

@@ -39,15 +26,15 @@ describe('Plugin', () => {
3926
await writeParquet(
4027
[
4128
{
42-
url: '/docs/foundry/ontology/overview/',
29+
url: '/foundry/ontology/overview/',
4330
title: 'Ontology Overview',
4431
content: 'This is the ontology overview content.',
4532
wordCount: 6,
4633
meta: {},
4734
fetchedAt: '2025-01-01T00:00:00.000Z',
4835
},
4936
{
50-
url: '/docs/foundry/actions/',
37+
url: '/foundry/actions/',
5138
title: 'Actions',
5239
content: 'Actions documentation content.',
5340
wordCount: 3,
@@ -59,6 +46,67 @@ describe('Plugin', () => {
5946
);
6047
}
6148

49+
async function seedDatabaseMany(): Promise<void> {
50+
fs.mkdirSync(path.join(tmpDir, 'data'), { recursive: true });
51+
52+
const rows: Array<{
53+
url: string;
54+
title: string;
55+
content: string;
56+
wordCount: number;
57+
meta: Record<string, unknown>;
58+
fetchedAt: string;
59+
}> = [];
60+
61+
// Intentionally unsorted URL insertion to prove deterministic ordering.
62+
rows.push({
63+
url: '/apollo/zzz/',
64+
title: 'Apollo ZZZ',
65+
content: 'apollo content',
66+
wordCount: 2,
67+
meta: {},
68+
fetchedAt: '2025-01-01T00:00:00.000Z',
69+
});
70+
rows.push({
71+
url: '/foundry/zzz/',
72+
title: 'Foundry ZZZ',
73+
content: 'foundry content',
74+
wordCount: 2,
75+
meta: {},
76+
fetchedAt: '2025-01-01T00:00:00.000Z',
77+
});
78+
rows.push({
79+
url: '/gotham/aaa/',
80+
title: 'Gotham AAA',
81+
content: 'gotham content',
82+
wordCount: 2,
83+
meta: {},
84+
fetchedAt: '2025-01-01T00:00:00.000Z',
85+
});
86+
87+
rows.push({
88+
url: '/foundry/compute-modules/overview/',
89+
title: 'Compute modules',
90+
content: 'Compute modules overview content.',
91+
wordCount: 4,
92+
meta: {},
93+
fetchedAt: '2025-01-01T00:00:00.000Z',
94+
});
95+
96+
for (let i = 0; i < 60; i += 1) {
97+
rows.push({
98+
url: `/foundry/many/${String(i).padStart(2, '0')}/`,
99+
title: `Foundry Many ${i}`,
100+
content: 'foundry many content',
101+
wordCount: 3,
102+
meta: {},
103+
fetchedAt: '2025-01-01T00:00:00.000Z',
104+
});
105+
}
106+
107+
await writeParquet(rows, dbPath);
108+
}
109+
62110
it('returns Hooks with tool property containing exactly 2 tools', async () => {
63111
const hooks = await plugin({ worktree: tmpDir });
64112

@@ -76,6 +124,8 @@ describe('Plugin', () => {
76124
expect(getDocPage.description).toBeTruthy();
77125
expect(getDocPage.description).toContain('documentation page');
78126
expect(getDocPage.args).toHaveProperty('url');
127+
expect(getDocPage.args).toHaveProperty('query');
128+
expect(getDocPage.args).toHaveProperty('scope');
79129
});
80130

81131
it('list_all_docs tool has description and no required args', async () => {
@@ -84,15 +134,16 @@ describe('Plugin', () => {
84134

85135
expect(listAllDocs.description).toBeTruthy();
86136
expect(listAllDocs.description).toContain('documentation');
87-
expect(Object.keys(listAllDocs.args)).toHaveLength(0);
137+
const keys = Object.keys(listAllDocs.args).sort((a, b) => a.localeCompare(b));
138+
expect(keys).toEqual(['limit', 'offset', 'query', 'scope']);
88139
});
89140

90141
it('get_doc_page execute returns page content when DB exists', async () => {
91142
await seedDatabase();
92143
const hooks = await plugin({ worktree: tmpDir });
93144

94145
const result = await hooks.tool['get_doc_page'].execute(
95-
{ url: '/docs/foundry/ontology/overview/' },
146+
{ url: '/foundry/ontology/overview/' },
96147
{}
97148
);
98149

@@ -115,9 +166,127 @@ describe('Plugin', () => {
115166

116167
const result = await hooks.tool['list_all_docs'].execute({}, {});
117168

118-
expect(result).toContain('Available Palantir Foundry Documentation (2 pages)');
119-
expect(result).toContain('- Ontology Overview (/docs/foundry/ontology/overview/)');
120-
expect(result).toContain('- Actions (/docs/foundry/actions/)');
169+
expect(result).toContain('Available Palantir Documentation Pages');
170+
expect(result).toContain('scope=foundry');
171+
expect(result).toContain('query=');
172+
expect(result).toContain('total=2');
173+
expect(result).toContain('- Ontology Overview (/foundry/ontology/overview/)');
174+
expect(result).toContain('- Actions (/foundry/actions/)');
175+
});
176+
177+
it('list_all_docs defaults to bounded results and foundry scope', async () => {
178+
await seedDatabaseMany();
179+
const hooks = await plugin({ worktree: tmpDir });
180+
181+
const result = await hooks.tool['list_all_docs'].execute({}, {});
182+
183+
expect(result).toContain('scope=foundry');
184+
expect(result).toContain('limit=50');
185+
expect(result).toContain('Next: call list_all_docs');
186+
expect(result).not.toContain('/apollo/');
187+
expect(result).not.toContain('/gotham/');
188+
189+
const lineCount = result.split('\n').filter((l) => l.startsWith('- ')).length;
190+
expect(lineCount).toBeLessThanOrEqual(50);
191+
});
192+
193+
it('list_all_docs pagination is deterministic (sorted by url)', async () => {
194+
await seedDatabaseMany();
195+
const hooks = await plugin({ worktree: tmpDir });
196+
197+
const first = await hooks.tool['list_all_docs'].execute(
198+
{ scope: 'all', limit: 1, offset: 0 },
199+
{}
200+
);
201+
const second = await hooks.tool['list_all_docs'].execute(
202+
{ scope: 'all', limit: 1, offset: 1 },
203+
{}
204+
);
205+
206+
const getOnlyUrl = (text: string): string => {
207+
const line = text.split('\n').find((l) => l.startsWith('- ') && l.includes('(/'));
208+
expect(line).toBeTruthy();
209+
const match = line?.match(/\((\/[^)]+)\)/);
210+
expect(match?.[1]).toBeTruthy();
211+
return match?.[1] as string;
212+
};
213+
214+
const url0 = getOnlyUrl(first);
215+
const url1 = getOnlyUrl(second);
216+
expect(url0).not.toBe(url1);
217+
218+
// Because ordering is by URL, /apollo/... sorts before /foundry/... and /gotham/...
219+
expect(url0).toBe('/apollo/zzz/');
220+
expect(url1).toBe('/foundry/compute-modules/overview/');
221+
});
222+
223+
it('list_all_docs scope=all includes non-foundry URLs', async () => {
224+
await seedDatabaseMany();
225+
const hooks = await plugin({ worktree: tmpDir });
226+
227+
const result = await hooks.tool['list_all_docs'].execute(
228+
{ scope: 'all', limit: 5, offset: 0 },
229+
{}
230+
);
231+
232+
expect(result).toContain('scope=all');
233+
expect(result).toContain('/apollo/zzz/');
234+
});
235+
236+
it('list_all_docs query filters and ranks results', async () => {
237+
await seedDatabaseMany();
238+
const hooks = await plugin({ worktree: tmpDir });
239+
240+
const result = await hooks.tool['list_all_docs'].execute(
241+
{ query: 'compute modules', scope: 'foundry', limit: 10, offset: 0 },
242+
{}
243+
);
244+
245+
expect(result).toContain('scope=foundry');
246+
expect(result).toContain('query=compute modules');
247+
expect(result).toContain('/foundry/compute-modules/overview/');
248+
});
249+
250+
it('get_doc_page accepts common URL variants (missing /docs prefix, missing trailing slash)', async () => {
251+
await seedDatabaseMany();
252+
const hooks = await plugin({ worktree: tmpDir });
253+
254+
const result = await hooks.tool['get_doc_page'].execute(
255+
{ url: '/docs/foundry/compute-modules/overview' },
256+
{}
257+
);
258+
259+
expect(result).toContain('Compute modules overview content.');
260+
});
261+
262+
it('get_doc_page can resolve a page from a free-text query', async () => {
263+
await seedDatabaseMany();
264+
const hooks = await plugin({ worktree: tmpDir });
265+
266+
const result = await hooks.tool['get_doc_page'].execute(
267+
{ query: 'compute modules', scope: 'foundry' },
268+
{}
269+
);
270+
271+
expect(result).toContain('Matched:');
272+
expect(result).toContain('/foundry/compute-modules/overview/');
273+
expect(result).toContain('Compute modules overview content.');
274+
});
275+
276+
it('list_all_docs invalid args fail safely', async () => {
277+
await seedDatabaseMany();
278+
const hooks = await plugin({ worktree: tmpDir });
279+
280+
const badLimit = await hooks.tool['list_all_docs'].execute({ limit: 0 } as never, {});
281+
expect(badLimit).toContain('[ERROR]');
282+
expect(badLimit).toContain('limit');
283+
284+
const badOffset = await hooks.tool['list_all_docs'].execute({ offset: -1 } as never, {});
285+
expect(badOffset).toContain('[ERROR]');
286+
expect(badOffset).toContain('offset');
287+
288+
const badScope = await hooks.tool['list_all_docs'].execute({ scope: 'nope' } as never, {});
289+
expect(badScope).toContain('[ERROR]');
121290
});
122291

123292
it('tools return helpful message when docs.db does not exist', async () => {
@@ -185,6 +354,6 @@ describe('Plugin', () => {
185354

186355
// Second call reuses cached DB — list_all_docs also works
187356
const listResult = await hooks.tool['list_all_docs'].execute({}, {});
188-
expect(listResult).toContain('2 pages');
357+
expect(listResult).toContain('total=2');
189358
});
190359
});

src/__tests__/palantirMcpRescan.test.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { mock } from 'bun:test';
32
import fs from 'node:fs';
43
import path from 'node:path';
54
import os from 'node:os';
@@ -29,18 +28,6 @@ function getFirstTextPart(output: CommandHookOutput): string {
2928
return part.text;
3029
}
3130

32-
mock.module('@opencode-ai/plugin/tool', () => {
33-
const mockSchema = {
34-
string: () => ({
35-
describe: (d: string) => ({ _type: 'string', _description: d }),
36-
}),
37-
};
38-
const toolFn = Object.assign((input: Record<string, unknown>) => input, {
39-
schema: mockSchema,
40-
});
41-
return { tool: toolFn };
42-
});
43-
4431
const plugin = (await import('../index.ts')).default as unknown as MinimalPlugin;
4532

4633
describe('/rescan-palantir-mcp-tools', () => {

src/__tests__/palantirMcpSetup.test.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { mock } from 'bun:test';
32
import fs from 'node:fs';
43
import path from 'node:path';
54
import os from 'node:os';
@@ -43,18 +42,6 @@ function getFirstTextPart(output: CommandHookOutput): string {
4342
return part.text;
4443
}
4544

46-
mock.module('@opencode-ai/plugin/tool', () => {
47-
const mockSchema = {
48-
string: () => ({
49-
describe: (d: string) => ({ _type: 'string', _description: d }),
50-
}),
51-
};
52-
const toolFn = Object.assign((input: Record<string, unknown>) => input, {
53-
schema: mockSchema,
54-
});
55-
return { tool: toolFn };
56-
});
57-
5845
const plugin = (await import('../index.ts')).default as unknown as MinimalPlugin;
5946

6047
describe('/setup-palantir-mcp', () => {

0 commit comments

Comments
 (0)