This commit is contained in:
@@ -162,6 +162,193 @@ describe('web tools', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('source library routing', () => {
|
||||
let workspacePath = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (workspacePath) {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('DownloadFile section=source saves under source/ and appends index.jsonl', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('PNGDATA', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
})));
|
||||
|
||||
const result = await executeTool(
|
||||
'DownloadFile',
|
||||
{ url: 'https://example.com/chart.png', filename: 'chart.png', section: 'source' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(fs.existsSync(path.join(workspacePath, 'source', 'chart.png'))).toBe(true);
|
||||
const idx = fs.readFileSync(path.join(workspacePath, 'source', 'index.jsonl'), 'utf-8')
|
||||
.trim().split('\n').map((l) => JSON.parse(l) as Record<string, unknown>);
|
||||
expect(idx).toHaveLength(1);
|
||||
expect(idx[0]?.url).toBe('https://example.com/chart.png');
|
||||
expect(idx[0]?.tool).toBe('DownloadFile');
|
||||
expect(idx[0]?.file).toBe('chart.png');
|
||||
expect(idx[0]?.bytes).toBe(Buffer.byteLength('PNGDATA'));
|
||||
});
|
||||
|
||||
it('DownloadFile section=input does NOT write source/index.jsonl', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('DATA', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
})));
|
||||
|
||||
const result = await executeTool(
|
||||
'DownloadFile',
|
||||
{ url: 'https://example.com/file.bin', filename: 'file.bin' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(fs.existsSync(path.join(workspacePath, 'input', 'file.bin'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(workspacePath, 'source', 'index.jsonl'))).toBe(false);
|
||||
});
|
||||
|
||||
it('WebFetch persists source .md + index entry when ctx.spaceId is set', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
'<html><body><h1>Title</h1><p>Body content here</p></body></html>',
|
||||
{ status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } },
|
||||
)));
|
||||
|
||||
const ctx: ToolContext = {
|
||||
workspacePath,
|
||||
editAllowed: false,
|
||||
spaceId: 'space-123',
|
||||
toolsConfig: { webfetchAllowedHosts: ['example.com'] },
|
||||
};
|
||||
const result = await executeTool('WebFetch', { url: 'https://example.com/article' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
const idx = fs.readFileSync(path.join(workspacePath, 'source', 'index.jsonl'), 'utf-8')
|
||||
.trim().split('\n').map((l) => JSON.parse(l) as Record<string, unknown>);
|
||||
expect(idx).toHaveLength(1);
|
||||
expect(idx[0]?.url).toBe('https://example.com/article');
|
||||
expect(idx[0]?.tool).toBe('WebFetch');
|
||||
const savedFile = idx[0]?.file as string;
|
||||
expect(savedFile.endsWith('.md')).toBe(true);
|
||||
const body = fs.readFileSync(path.join(workspacePath, 'source', savedFile), 'utf-8');
|
||||
expect(body).toContain('Body content here');
|
||||
});
|
||||
|
||||
it('WebFetch dedupes the same URL within a space (no second source file)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
'<html><body><p>Stuff</p></body></html>',
|
||||
{ status: 200, headers: { 'content-type': 'text/html' } },
|
||||
)));
|
||||
|
||||
const ctx: ToolContext = {
|
||||
workspacePath,
|
||||
editAllowed: false,
|
||||
spaceId: 'space-1',
|
||||
toolsConfig: { webfetchAllowedHosts: ['example.com'] },
|
||||
};
|
||||
await executeTool('WebFetch', { url: 'https://example.com/dup' }, ctx);
|
||||
await executeTool('WebFetch', { url: 'https://example.com/dup' }, ctx);
|
||||
|
||||
const idx = fs.readFileSync(path.join(workspacePath, 'source', 'index.jsonl'), 'utf-8')
|
||||
.trim().split('\n').filter(Boolean);
|
||||
expect(idx).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('WebFetch does NOT create source/ when ctx.spaceId is undefined (legacy)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
'<html><body><p>Non-space content</p></body></html>',
|
||||
{ status: 200, headers: { 'content-type': 'text/html' } },
|
||||
)));
|
||||
|
||||
const result = await executeTool('WebFetch', { url: 'https://example.com/page' }, makeContext(workspacePath));
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('Non-space content');
|
||||
expect(fs.existsSync(path.join(workspacePath, 'source'))).toBe(false);
|
||||
});
|
||||
|
||||
it('WebFetch saves FULL clean markdown to source/ but returns TRUNCATED text to the agent', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
// 本文を 10k 文字を大きく超える長さにして、source(全文) と agent(切り詰め) の差を作る。
|
||||
const longBody = 'word '.repeat(6000); // ~30k chars
|
||||
const html = `<html><head><title>Long Article</title></head><body>
|
||||
<nav>NAV MENU LINKS</nav>
|
||||
<article><h1>Long Article Heading</h1><p>${longBody}</p></article>
|
||||
<footer>FOOTER JUNK</footer>
|
||||
</body></html>`;
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } },
|
||||
)));
|
||||
|
||||
const ctx: ToolContext = {
|
||||
workspacePath,
|
||||
editAllowed: false,
|
||||
spaceId: 'space-long',
|
||||
toolsConfig: { webfetchAllowedHosts: ['example.com'] },
|
||||
};
|
||||
const result = await executeTool('WebFetch', { url: 'https://example.com/long' }, ctx);
|
||||
expect(result?.isError).toBe(false);
|
||||
|
||||
// agent への返却は切り詰め + source 案内ノート
|
||||
expect(result!.output.length).toBeLessThan(11000);
|
||||
expect(result!.output).toContain('full version saved to source/');
|
||||
// クリーン抽出されているので boilerplate は出ない
|
||||
expect(result!.output).not.toContain('NAV MENU LINKS');
|
||||
expect(result!.output).not.toContain('FOOTER JUNK');
|
||||
|
||||
// source/ には全文(agent 返却より長い)が保存される
|
||||
const idx = fs.readFileSync(path.join(workspacePath, 'source', 'index.jsonl'), 'utf-8')
|
||||
.trim().split('\n').map((l) => JSON.parse(l) as Record<string, unknown>);
|
||||
expect(idx).toHaveLength(1);
|
||||
const savedFile = idx[0]?.file as string;
|
||||
const saved = fs.readFileSync(path.join(workspacePath, 'source', savedFile), 'utf-8');
|
||||
expect(saved.length).toBeGreaterThan(20000);
|
||||
expect(saved.length).toBeGreaterThan(result!.output.length);
|
||||
expect(saved).not.toContain('NAV MENU LINKS');
|
||||
});
|
||||
|
||||
it('WebFetch (non-space) returns clean truncated text without a source note', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const longBody = 'lorem '.repeat(6000);
|
||||
const html = `<html><head><title>NS</title></head><body>
|
||||
<article><h1>Heading</h1><p>${longBody}</p></article></body></html>`;
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
html, { status: 200, headers: { 'content-type': 'text/html' } },
|
||||
)));
|
||||
|
||||
const result = await executeTool('WebFetch', { url: 'https://example.com/ns' }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result!.output.length).toBeLessThan(11000);
|
||||
expect(result!.output).toContain('... (truncated)');
|
||||
expect(result!.output).not.toContain('saved to source');
|
||||
expect(fs.existsSync(path.join(workspacePath, 'source'))).toBe(false);
|
||||
});
|
||||
|
||||
it('WebFetch falls back to naive extraction on non-article HTML (never hard-fails)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
// readability が本文を見つけられない最小 HTML
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
'<html><body>bare text with no article structure</body></html>',
|
||||
{ status: 200, headers: { 'content-type': 'text/html' } },
|
||||
)));
|
||||
|
||||
const result = await executeTool('WebFetch', { url: 'https://example.com/bare' }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result!.output).toContain('bare text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSearchResultsFromText', () => {
|
||||
it('extracts URLs with surrounding text', () => {
|
||||
const text = [
|
||||
|
||||
Reference in New Issue
Block a user