feat: source citations with footnotes in the transcript (#21)
This commit was merged in pull request #21.
This commit is contained in:
+120
-21
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSourceList } from "../lib/citations";
|
||||
import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor } from "../lib/tools";
|
||||
|
||||
/**
|
||||
@@ -86,7 +87,7 @@ describe("web_search", () => {
|
||||
expect(requestBody()).toEqual({ query: "vicinae launcher", max_results: 3 });
|
||||
});
|
||||
|
||||
it("renders results as numbered title, url, and snippet", async () => {
|
||||
it("renders each result under the number the model will cite", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
@@ -99,9 +100,9 @@ describe("web_search", () => {
|
||||
const result = await executeTool("web_search", { query: "x" }, context);
|
||||
|
||||
expect(result.content).toContain('Web results for "x"');
|
||||
expect(result.content).toContain("1. First");
|
||||
expect(result.content).toContain("[1] First");
|
||||
expect(result.content).toContain("https://first.example");
|
||||
expect(result.content).toContain("2. Second");
|
||||
expect(result.content).toContain("[2] Second");
|
||||
});
|
||||
|
||||
it("omits max_results when the model does not ask for it", async () => {
|
||||
@@ -177,15 +178,13 @@ describe("web_fetch", () => {
|
||||
expect(request().url).toBe("https://ollama.com/api/web_fetch");
|
||||
expect(requestBody()).toEqual({ url: "https://example.com" });
|
||||
|
||||
expect(result.content).toContain("Title: Example");
|
||||
expect(result.content).toContain("[1] Example");
|
||||
expect(result.content).toContain("Body text.");
|
||||
expect(result.content).toContain("https://a.example");
|
||||
});
|
||||
|
||||
it("truncates an enormous page and says so", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ title: "Huge", content: "y".repeat(20_000), links: [] }),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ title: "Huge", content: "y".repeat(20_000), links: [] }));
|
||||
|
||||
const result = await executeTool("web_fetch", { url: "https://huge.example" }, context);
|
||||
|
||||
@@ -198,7 +197,7 @@ describe("web_fetch", () => {
|
||||
|
||||
const result = await executeTool("web_fetch", { url: "https://untitled.example" }, context);
|
||||
|
||||
expect(result.content).toContain("Title: https://untitled.example");
|
||||
expect(result.content).toContain("[1] https://untitled.example");
|
||||
});
|
||||
|
||||
it("rejects a call with no url without touching the network", async () => {
|
||||
@@ -210,12 +209,104 @@ describe("web_fetch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sources", () => {
|
||||
it("keeps numbering across the calls in one turn", async () => {
|
||||
// Two searches in a turn must not each start at 1, or a marker in the answer
|
||||
// would point at two different pages at once.
|
||||
const list = createSourceList();
|
||||
const turn = { ...context, sources: list };
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{ title: "First", url: "https://first.example", content: "First body" },
|
||||
{ title: "Second", url: "https://second.example", content: "Second body" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "Third", url: "https://third.example", content: "Third" }] }),
|
||||
);
|
||||
|
||||
const first = await executeTool("web_search", { query: "one" }, turn);
|
||||
const second = await executeTool("web_search", { query: "two" }, turn);
|
||||
|
||||
expect(first.content).toContain("[1] First");
|
||||
expect(second.content).toContain("[3] Third");
|
||||
expect(second.sources?.map((source) => source.index)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("returns only what the call itself added", async () => {
|
||||
// The turn's whole list is the flattening of its records, so a record that
|
||||
// repeated the running total would store the same page once per later call.
|
||||
const list = createSourceList();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{ title: "First", url: "https://first.example", content: "First body" },
|
||||
{ title: "Second", url: "https://second.example", content: "Second body" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executeTool("web_search", { query: "x" }, { ...context, sources: list });
|
||||
|
||||
expect(result.sources?.map((source) => source.index)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("gives a page the number it already has, however the turn reached it", async () => {
|
||||
const list = createSourceList();
|
||||
const turn = { ...context, sources: list };
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "First", url: "https://first.example", content: "B" }] }),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ title: "First", content: "body", links: [] }));
|
||||
|
||||
await executeTool("web_search", { query: "x" }, turn);
|
||||
const page = await executeTool("web_fetch", { url: "https://first.example" }, turn);
|
||||
|
||||
expect(page.content).toContain("[1] First");
|
||||
// Nothing new, so nothing for the turn to store a second time.
|
||||
expect(page.sources).toEqual([]);
|
||||
});
|
||||
|
||||
it("numbers from one when the caller keeps no list of its own", async () => {
|
||||
// The format must not depend on whether anyone is collecting: a test or a
|
||||
// future caller with no interest in citations sees the same numbering.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "First", url: "https://first.example", content: "B" }] }),
|
||||
);
|
||||
|
||||
const result = await executeTool("web_search", { query: "x" }, context);
|
||||
|
||||
expect(result.content).toContain("[1] First");
|
||||
});
|
||||
|
||||
it("prints no number for a result with no address to link to", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ results: [{ title: "Nowhere", url: " ", content: "Body" }] }),
|
||||
);
|
||||
|
||||
const result = await executeTool("web_search", { query: "x" }, context);
|
||||
|
||||
expect(result.content).toContain("Nowhere");
|
||||
expect(result.content).not.toContain("[0]");
|
||||
expect(result.sources).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local tools", () => {
|
||||
it("uses the injected clock instead of the wall clock", async () => {
|
||||
const result = await executeTool("get_datetime", {}, {
|
||||
...context,
|
||||
now: new Date("2026-01-02T03:04:05Z"),
|
||||
});
|
||||
const result = await executeTool(
|
||||
"get_datetime",
|
||||
{},
|
||||
{
|
||||
...context,
|
||||
now: new Date("2026-01-02T03:04:05Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("2026-01-02T03:04:05.000Z");
|
||||
@@ -245,10 +336,14 @@ describe("local tools", () => {
|
||||
});
|
||||
|
||||
it("puts the configured place first and says to prefer it", async () => {
|
||||
const result = await executeTool("get_location", {}, {
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
});
|
||||
const result = await executeTool(
|
||||
"get_location",
|
||||
{},
|
||||
{
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.content).toContain("Place: Cebu City, Philippines");
|
||||
expect(result.content).toContain("prefer it");
|
||||
@@ -294,11 +389,15 @@ describe("get_location with IP geolocation", () => {
|
||||
// would be a privacy cost for nothing.
|
||||
const ipPlace = vi.fn(async () => resolved);
|
||||
|
||||
const result = await executeTool("get_location", {}, {
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
ipPlace,
|
||||
});
|
||||
const result = await executeTool(
|
||||
"get_location",
|
||||
{},
|
||||
{
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
ipPlace,
|
||||
},
|
||||
);
|
||||
|
||||
expect(ipPlace).not.toHaveBeenCalled();
|
||||
expect(result.content).toContain("Place: Cebu City, Philippines");
|
||||
|
||||
Reference in New Issue
Block a user