Enable automatic web search by default; improve toggle behavior and search planning.
CI / check (22) (push) Successful in 9m35s
CI / check (24) (push) Successful in 9m34s

This commit is contained in:
2026-10-06 08:50:03 +02:00
parent 674abf78f6
commit 7be654af11
17 changed files with 300 additions and 54 deletions
+3 -1
View File
@@ -36,7 +36,9 @@ async function setup(t, {visionResult = null, replyResult = null, visionModel =
searxngUrl: `http://127.0.0.1:${searchServer.address().port}`,
eventLoopLagMonitorEnabled: false, slowRequestLoggingEnabled: false, structuredRequestLoggingEnabled: false},
modelOverrides: {fetch: async (_url, options) => {
const body = JSON.parse(options.body); requests.push(body);
const body = JSON.parse(options.body);
if (body.format?.properties?.search) return new Response(JSON.stringify({message: {content: '{"search":false,"query":""}'}, done: true}));
requests.push(body);
order.push(body.model === visionModel ? "vision" : "answer");
if (holdVision && body.model === visionModel) return new Promise((_resolve, reject) => {
startedVision();
+101
View File
@@ -0,0 +1,101 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createServer} from "node:http";
import {createApp} from "../src/app.js";
async function listen(t, handler) {
const server = createServer(handler);
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }));
return `http://127.0.0.1:${server.address().port}`;
}
test("automatic search defaults on for sends, follow-ups, retries and edited branches; opt-out bypasses planning", async t => {
const order = [], plans = [], answers = [], queries = [];
let search = true;
const query = "Swiss rail pass visitor comparison";
const searxngUrl = await listen(t, (req, res) => {
order.push("search");
queries.push(new URL(req.url, "http://localhost").searchParams.get("q"));
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({results: [{title: "Swiss rail pass comparison", url: "https://example.org/passes",
content: "Compare Swiss rail pass options for visitors and their trips."}]}));
});
const app = createApp({config: {dbPath: ":memory:", testMode: true, sessionSecret: "automatic-search-test", searxngUrl,
chatSummaryUpdateEveryMessages: 0, eventLoopLagMonitorEnabled: false, slowRequestLoggingEnabled: false,
structuredRequestLoggingEnabled: false}, modelOverrides: {fetch: async (_url, options) => {
const body = JSON.parse(options.body);
if (body.format?.properties?.search) {
order.push("plan");
plans.push(body);
return new Response(JSON.stringify({message: {content: JSON.stringify({search, query: search ? query : ""})}, done: true}));
}
order.push("answer");
answers.push(body);
return new Response(JSON.stringify({message: {content: "A helpful answer."}, done: true}));
}}});
t.after(() => app.locals.close());
const base = await listen(t, app);
let cookie;
const request = async (path, body) => {
const response = await fetch(base + path, {method: body ? "POST" : "GET",
headers: {"Content-Type": "application/json", ...(cookie ? {Cookie: cookie} : {})},
body: body ? JSON.stringify(body) : undefined});
if (response.headers.get("set-cookie")) cookie = response.headers.get("set-cookie").split(";")[0];
assert.equal(response.status, 200);
return response;
};
const api = async (path, body) => (await request(path, body)).json();
await api("/register", {username: "automatic-search", password: "test-password"});
const chatId = (await api("/chats", {title: "Rail passes"})).chat.id;
const question = "Which rail pass suits my trip to Switzerland?";
const wire = await (await request("/chat/stream", {chatId, message: question, model: "qwen3:14b", reasoning: true})).text();
assert.deepEqual(order, ["plan", "search", "answer"]);
assert.match(wire, /"status":"searching"/);
assert.match(wire, /"searchState":"succeeded"/);
assert.doesNotMatch(wire, /"query"|"search":true/);
assert.equal(plans[0].model, "qwen3:14b");
assert.equal(plans[0].think, false);
assert.equal(answers[0].think, true);
assert.ok(answers[0].messages.some(message => message.content.startsWith("<web_search_results>")));
let messages = (await api(`/chats/${chatId}/messages`)).messages;
assert.deepEqual(messages.map(message => message.content), [question, "A helpful answer."]);
assert.equal(messages[1].sources.length, 1);
const botId = messages[1].id, userId = messages[0].id;
const followup = await api("/chat", {chatId, message: "And which one should I choose?"});
assert.equal(followup.searchState, "succeeded");
assert.deepEqual(queries, [query, query]);
assert.equal(JSON.parse(plans.at(-1).messages.at(-1).content).history[0].content, question);
search = false;
order.length = 0;
const skipped = await api("/chat", {chatId, message: "Write a fictional breaking news story"});
assert.equal(skipped.searchState, "skipped");
assert.equal(skipped.sources, undefined);
assert.deepEqual(order, ["plan", "answer"]);
assert.ok(!answers.at(-1).messages.some(message => message.content.startsWith("<web_search_results>")));
search = true;
order.length = 0;
const disabled = await api("/chat", {chatId, message: "Search the web for rail passes", webSearch: false});
assert.equal(disabled.searchState, "skipped");
assert.deepEqual(order, ["answer"]);
for (const endpoint of [`/chats/${chatId}/messages/${botId}/retry/stream`, `/chats/${chatId}/messages/by-index/1/retry`]) {
order.length = 0;
await (await request(endpoint, {})).text();
assert.deepEqual(order, ["plan", "search", "answer"]);
const context = JSON.parse(plans.at(-1).messages.at(-1).content);
assert.equal(context.message, question);
assert.deepEqual(context.history, [], "A retry must not use later conversation turns");
}
order.length = 0;
const edited = await api(`/chats/${chatId}/messages/${userId}/edit-resend`, {
chatId, message: "Which rail pass is suitable for my Swiss trip?", confirmed: true
});
assert.notEqual(edited.chat.id, chatId);
assert.deepEqual(order, ["plan", "search", "answer"]);
messages = (await api(`/chats/${edited.chat.id}/messages`)).messages;
assert.equal(messages.at(-1).sources.length, 1);
});
+24 -2
View File
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import {createComposerController} from "../public/js/composerController.js";
import {createConversationState} from "../public/js/conversationState.js";
import {createWebSearchToggle} from "../public/js/webSearch.js";
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
const noop = () => {};
@@ -11,9 +12,11 @@ function fixture(overrides = {}) {
const state = createConversationState();
const elements = {msgInput: element(), sendBtn: element(), messagesDiv: element(), modelSelect: {value: "original-model"}};
const calls = {create: 0, streams: [], refresh: [], notices: []};
const webSearchButton = {...element(), addEventListener: (_event, handler) => { webSearchButton.click = handler; }};
const webSearchToggle = createWebSearchToggle(webSearchButton);
const controller = createComposerController({state, elements,
attachmentComposer: {pending: false, attachments: [], setBusy: noop, clear: noop, clearSentDraft: noop},
webSearchButton: element(), reasoningToggle: {enabled: true, setBusy: noop, reset: noop}, webSearchToggle: {enabled: true, reset: noop},
webSearchButton, reasoningToggle: {enabled: true, setBusy: noop, reset: noop}, webSearchToggle,
setChatActivity: noop, updateComposerPlaceholder: noop, setNotice: message => calls.notices.push(message),
createNewChat: async () => {
calls.create++;
@@ -32,9 +35,28 @@ function fixture(overrides = {}) {
handlers.onDone({reply: "Done"});
}}
});
return {state, elements, calls, ...controller};
return {state, elements, calls, webSearchButton, webSearchToggle, ...controller};
}
test("search stays enabled across sends and honors a persistent manual opt-out", async () => {
const s = fixture();
for (const message of ["First question", "Follow-up question"]) {
s.elements.msgInput.value = message;
await s.sendMessage();
}
s.webSearchButton.click();
for (const message of ["Offline question", "Another offline question"]) {
s.elements.msgInput.value = message;
await s.sendMessage();
}
assert.deepEqual(s.calls.streams.map(payload => payload.webSearch), [true, true, false, false]);
assert.equal(s.webSearchToggle.enabled, false);
s.webSearchButton.click();
s.elements.msgInput.value = "Search is allowed again";
await s.sendMessage();
assert.equal(s.calls.streams.at(-1).webSearch, true);
});
test("rapid sends lock before chat creation and keep the captured chat/model after navigation during rename", async () => {
const creation = deferred(), rename = deferred();
const s = fixture({creation: creation.promise, rename: rename.promise});
+6 -1
View File
@@ -24,7 +24,12 @@ test("thinking streams separately and survives reload, retry, branch and import"
modelApiBaseUrl: "http://fixture", modelRequestTimeoutMs: 1000, modelUnloadAfterMs: 60000
}, {
fetch: async (_url, options) => {
requests.push(JSON.parse(options.body));
const body = JSON.parse(options.body);
if (body.format?.properties?.search) {
const {message} = JSON.parse(body.messages.at(-1).content);
return new Response(JSON.stringify({message: {content: JSON.stringify({search: message.includes("latest news"), query: message})}, done: true}));
}
requests.push(body);
const chunks = [
'{"message":{"thinking":"Consider "}}\n{"message":{"thi',
'nking":"carefully.","content":""}}\n',
+6 -3
View File
@@ -22,7 +22,10 @@ async function fixture(t) {
});
await new Promise(resolve => search.listen(0, "127.0.0.1", resolve));
const modelService = {
async generateReply() { return `Original ${++sequence}`; },
async generateReply(_model, _messages, options) {
if (options.format?.properties?.search) return '{"search":false,"query":""}';
return `Original ${++sequence}`;
},
async streamReply(model, messages, chunk, options) {
calls.push({model, messages, options});
if (options.reasoning) options.onThinking("New reasoning", "New reasoning");
@@ -59,7 +62,7 @@ async function fixture(t) {
};
await api("/register", {username: "regenerator", password: "test-password"});
const chatId = (await (await api("/chats", {title: "Versions"})).json()).chat.id;
await api("/chat", {chatId, message: "Search the web for the latest satellite launch news today"});
await api("/chat", {chatId, message: "Search the web for the latest satellite launch news today", webSearch: false});
await api("/chat", {chatId, message: "Later private question"});
const messages = (await (await api(`/chats/${chatId}/messages`)).json()).messages;
const bot = messages[1];
@@ -122,7 +125,7 @@ test("regeneration streams before commit in the same message; versions retain so
assert.equal((await s.history())[1].content, "Edited original");
assert.equal((await s.api(`${s.path}/versions/99`, {})).status, 400);
s.setMode("success");
const third = await (await s.api(`${s.path}/retry/stream`, {})).text();
const third = await (await s.api(`${s.path}/retry/stream`, {webSearch: false})).text();
assert.match(third, /event: done/);
const thirdMessage = (await s.history())[1];
assert.equal(thirdMessage.versionCount, 3);
+1
View File
@@ -42,6 +42,7 @@ async function setup(t, visionDebug = true) {
slowRequestLoggingEnabled: false, structuredRequestLoggingEnabled: false
}, modelOverrides: {fetch: async (_url, options) => {
const body = JSON.parse(options.body);
if (body.format?.properties?.search) return new Response(JSON.stringify({message: {content: '{"search":false,"query":""}'}, done: true}));
requests.push(body);
const chunks = body.model === "qwen2.5vl:7b"
? [{message: {content: rawVisionResponse}, done: true}]
+63 -10
View File
@@ -7,7 +7,7 @@ import { createServer } from "node:http";
import { createApp } from "../src/app.js";
import { createChatService } from "../src/services/chatService.js";
import { runMigrations } from "../src/db/schema.js";
import { SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery, decideWebSearch, selectSearchResults, retrieveWebSearch } from "../src/services/webSearchService.js";
import { SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery, decideWebSearch, planWebSearch, selectSearchResults, retrieveWebSearch } from "../src/services/webSearchService.js";
import { createModelService } from "../src/services/modelService.js";
import { formatChatMessage } from "../src/services/chatMessageHelpers.js";
import { appendSources, createWebSearchToggle } from "../public/js/webSearch.js";
@@ -81,6 +81,55 @@ test("search decisions and source selection use only relevant, renumbered eviden
]);
});
test("the model can choose research beyond keywords and skip keyword matches", async () => {
let planningRequest;
const history = [{role: "user", content: "I am choosing a Swiss rail pass."}];
const modelService = {async generateReply(model, messages, options) {
planningRequest = {model, messages, options};
return '{"search":true,"query":"Swiss rail pass visitor comparison"}';
}};
let query;
const outcome = await retrieveWebSearch("Which one suits me?", {async search(value) {
query = value;
return [{title: "Swiss rail pass comparison", url: "https://example.org/passes", snippet: "Compare Swiss rail pass options for visitors and their trips."}];
}}, {modelService, model: "selected:model", history, referenceText: ""});
assert.equal(outcome.state, "succeeded");
assert.equal(query, "Swiss rail pass visitor comparison");
assert.equal(planningRequest.model, "selected:model");
assert.deepEqual(JSON.parse(planningRequest.messages.at(-1).content).history, history);
assert.equal(planningRequest.options.reasoning, false);
assert.equal(planningRequest.options.format.properties.search.type, "boolean");
const skipped = await retrieveWebSearch("Write a fictional breaking news story", {search() { assert.fail("The model chose not to search"); }}, {
modelService: {async generateReply() { return '{"search":false,"query":""}'; }}, model: "selected:model"
});
assert.equal(skipped.state, "skipped");
assert.equal(skipped.sources, null);
const explicit = await planWebSearch("Search the web for Swiss rail passes", {
modelService: {generateReply() { assert.fail("Explicit searches do not need planning"); }}
});
assert.equal(explicit.shouldSearch, true);
});
test("search planning handles invalid output and failure without swallowing cancellation", async () => {
for (const response of ["not JSON", "null", '{"search":"yes","query":"wrong"}', '{"search":true,"query":""}']) {
const modelService = {async generateReply() { return response; }};
assert.equal((await planWebSearch("latest news", {modelService})).shouldSearch, true);
assert.equal((await planWebSearch("hello", {modelService})).shouldSearch, false);
}
assert.equal((await planWebSearch("hello", {modelService: {async generateReply() { throw new Error("Offline"); }}})).shouldSearch, false);
const controller = new AbortController();
await assert.rejects(planWebSearch("hello", {signal: controller.signal, modelService: {async generateReply(_model, _messages, {signal}) {
controller.abort();
assert.equal(signal.aborted, true);
return '{"search":true,"query":"unused"}';
}}}), {name: "AbortError"});
const unavailable = await retrieveWebSearch("hello", new SearXNGSearchProvider({searxngUrl: ""}), {
modelService: {generateReply() { assert.fail("Do not plan use of an unconfigured search provider"); }}
});
assert.equal(unavailable.state, "skipped");
});
test("every Ollama request includes dynamic server date context", async () => {
let requestBody;
const modelService = createModelService({
@@ -147,7 +196,13 @@ test("chat search integrates with streaming, persistence, personas, settings, re
});
const calls = [];
const modelService = {
async generateReply(model, messages, options) { calls.push({ model, messages, options }); return "A fact [1]."; },
async generateReply(model, messages, options) {
if (options.format?.properties?.search) {
const {message} = JSON.parse(messages.at(-1).content);
return JSON.stringify({search: decideWebSearch(message).shouldSearch, query: message});
}
calls.push({ model, messages, options }); return "A fact [1].";
},
async streamReply(model, messages, onChunk, options) {
calls.push({ model, messages, options });
onChunk("A fact ", "A fact ");
@@ -222,7 +277,7 @@ test("chat search integrates with streaming, persistence, personas, settings, re
assert.equal(messages[0].sources, undefined);
assert.deepEqual(messages.at(-1).sources, [source]);
const botId = messages.at(-1).id;
const retry = await api(`/chats/${chatId}/messages/${botId}/retry`, {});
const retry = await api(`/chats/${chatId}/messages/${botId}/retry`, {webSearch: false});
assert.equal(retry.status, 200);
assert.deepEqual(retry.body.message.sources, []);
await api(`/chats/${chatId}/messages/${botId}/versions/0`, {});
@@ -379,23 +434,21 @@ class Element {
get childElementCount() { return this.children.length; }
}
test("composer toggle resets and source renderer supports old messages and safe numbered links", (t) => {
test("composer search defaults on and source renderer supports old messages and safe numbered links", (t) => {
const original = globalThis.document;
globalThis.document = { createElement: (tag) => new Element(tag) };
t.after(() => { if (original === undefined) delete globalThis.document; else globalThis.document = original; });
const button = new Element("button");
const toggle = createWebSearchToggle(button);
assert.equal(toggle.enabled, false);
assert.equal(button.title, "Search the web");
button.listeners.click();
assert.equal(toggle.enabled, true);
assert.equal(button.attributes["aria-pressed"], "true");
assert.equal(button.title, "Web search enabled");
assert.match(button.title, /Automatic web search on/);
button.listeners.click();
assert.equal(toggle.enabled, false);
assert.equal(button.attributes["aria-pressed"], "false");
assert.match(button.title, /Web search off/);
button.listeners.click();
toggle.reset();
assert.equal(toggle.enabled, false);
assert.equal(toggle.enabled, true);
const container = new Element("div");
appendSources(container, undefined);
appendSources(container, []);
+5
View File
@@ -5,6 +5,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import {createServer} from "node:http";
import {createApp} from "../src/app.js";
import {decideWebSearch} from "../src/services/webSearchService.js";
// Controlled protocol fixtures, not claims about real-world events or statistics.
const lifespan = {title: "Life expectancy estimates 2026", url: "https://health.example/2026", content: "The 2026 life expectancy estimate in this test dataset is 74 years."};
@@ -44,6 +45,10 @@ test("explicit/date searches ground the actual Ollama stream before any generati
eventLoopLagMonitorEnabled: false, structuredRequestLoggingEnabled: false, slowRequestLoggingEnabled: false
}, modelOverrides: {fetch: async (_url, options) => {
const body = JSON.parse(options.body);
if (body.format?.properties?.search) {
const {message} = JSON.parse(body.messages.at(-1).content);
return new Response(JSON.stringify({message: {content: JSON.stringify({search: decideWebSearch(message).shouldSearch, query: message})}, done: true}));
}
modelRequests.push(body);
if (expectedSearch) assert.equal(retrieved, true, "Search must finish before Ollama starts");
const chunks = [