feat: print a stable source number in the web tool results

Each result now carries the number the model cites, assigned from the turn's
list rather than from its position in one call, so three searches in a turn no
longer produce three runs of 1. The entries a call added come back beside the
content, for the transcript to store and draw.
This commit is contained in:
2026-10-07 16:09:30 +08:00
parent 56c48a5b03
commit 5d7eec53dc
2 changed files with 167 additions and 27 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");