feat: source citations with footnotes in the transcript (#21)

This commit was merged in pull request #21.
This commit is contained in:
2026-10-07 08:12:48 +00:00
parent 72a63e69e6
commit fcc6d26c9d
16 changed files with 2716 additions and 205 deletions
+120 -21
View File
@@ -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");